From b51c6b802409c5be5611a0d99942fe3a24480331 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 4 Aug 2026 04:10:24 +0000 Subject: [PATCH] feat(careers): open a PR adding a server-rendered /careers page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The careers widget paints its board client-side, which is fine for people and useless for crawlers — precisely the failure CrawlProof audits for. This adds the crawlable counterpart: a button on the careers dashboard that opens a pull request putting a real /careers route in the customer's own repo, so the roles ship as HTML on their domain with the JobPosting graph in the source. Deterministic, like install-tracker and install-ad. We write a file only where the framework is positively identified — Next.js App Router or Astro — and never overwrite a careers page that already exists. Every other repo gets an honest no-op saying the widget already covers them; writing a speculative page into someone's repo is worse than opening no PR at all. The generated file can't import from us, so the feed now returns the finished JobPosting graph alongside the roles. That keeps the schema.org rules in one tested place instead of a copy that drifts. The widget learns about server-rendered boards: a container marked data-cp-careers-ssr gets replaced rather than appended to, and its JSON-LD is left alone, so a visitor never sees each job twice and a crawler never reads a duplicate graph. Verified: tsc clean, 1357 tests pass, next build compiles. The generated page was emitted into this repo's own tsconfig to prove it compiles in a strict Next + TypeScript project. Escaping, the JSON-LD breakout guard, the overwrite protection, and the unknown-framework no-op were each broken on purpose to confirm a test catches them. Co-Authored-By: Claude Opus 5 --- .../[id]/stats/careers/careers-install.tsx | 207 ++++++++++++++ .../projects/[id]/stats/careers/page.tsx | 10 + app/api/careers/jobs/route.ts | 17 +- .../[id]/github/install-careers/route.ts | 156 +++++++++++ app/careers.js/route.ts | 16 +- lib/careers/page-templates.ts | 254 ++++++++++++++++++ lib/github/install-careers.ts | 226 ++++++++++++++++ lib/github/repo-options.ts | 76 ++++++ ...20260804120000_pr_runs_install_careers.sql | 7 + tests/careers-install-pr.test.ts | 205 ++++++++++++++ tests/careers-page-templates.test.ts | 207 ++++++++++++++ 11 files changed, 1376 insertions(+), 5 deletions(-) create mode 100644 app/(app)/projects/[id]/stats/careers/careers-install.tsx create mode 100644 app/api/projects/[id]/github/install-careers/route.ts create mode 100644 lib/careers/page-templates.ts create mode 100644 lib/github/install-careers.ts create mode 100644 lib/github/repo-options.ts create mode 100644 supabase/migrations/20260804120000_pr_runs_install_careers.sql create mode 100644 tests/careers-install-pr.test.ts create mode 100644 tests/careers-page-templates.test.ts diff --git a/app/(app)/projects/[id]/stats/careers/careers-install.tsx b/app/(app)/projects/[id]/stats/careers/careers-install.tsx new file mode 100644 index 00000000..a49d0e7b --- /dev/null +++ b/app/(app)/projects/[id]/stats/careers/careers-install.tsx @@ -0,0 +1,207 @@ +"use client"; + +// "Add a real /careers page to my repo" — the crawlable counterpart to the +// drop-in widget. +// +// Two steps on purpose. Detection is read-only and tells the user what we +// found before anything is written; only the second button opens a PR. People +// are reasonably wary of a button that commits to their repo sight unseen. + +import { useState } from "react"; +import type { RepoOption } from "@/lib/github/repo-options"; + +interface Detected { + framework: "next-app" | "astro"; + dir: string; + typescript: boolean; + evidence: string; +} + +interface InstallResult { + status: "opened" | "noop"; + prUrl?: string; + paths?: string[]; + detail: string; +} + +const FRAMEWORK_LABEL: Record = { + "next-app": "Next.js (App Router)", + astro: "Astro", +}; + +export function CareersInstall({ + projectId, + repos, + configured, + enabled, +}: { + projectId: string; + repos: RepoOption[]; + configured: boolean; + enabled: boolean; +}) { + const [open, setOpen] = useState(false); + const [repo, setRepo] = useState(repos[0]?.full_name ?? ""); + const [rootPath, setRootPath] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [detected, setDetected] = useState(undefined); + const [result, setResult] = useState(null); + + const selected = repos.find((r) => r.full_name === repo); + + async function call(mode: "detect" | "submit") { + if (!selected) return; + const [owner, name] = selected.full_name.split("/"); + setBusy(true); + setError(null); + try { + const res = await fetch(`/api/projects/${projectId}/github/install-careers`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + owner, + repo: name, + installation_id: selected.installation_id, + default_branch: selected.default_branch ?? undefined, + root_path: rootPath.trim() || undefined, + mode, + }), + }); + const payload = await res.json(); + if (!res.ok) { + setError(payload?.error ?? "Something went wrong."); + return; + } + if (mode === "detect") setDetected(payload.data.detected); + else setResult(payload.data); + } catch { + setError("Could not reach GitHub. Try again."); + } finally { + setBusy(false); + } + } + + if (!configured || repos.length === 0) return null; + + return ( +
+
+
+

Server-rendered careers page

+

+ The widget paints your board with JavaScript, which crawlers don't run. + Open a PR that adds a real /careers route + to your repo and the roles ship as HTML on your own domain. +

+
+ +
+ + {!enabled && ( +

+ Turn the careers widget on first — the generated page reads from this project's feed. +

+ )} + + {open && enabled && ( +
+
+ + +
+ +
+ + { + setRootPath(e.target.value); + setDetected(undefined); + }} + placeholder="apps/web — leave blank for the repo root" + className="w-full rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 text-sm" + /> +
+ + {error &&

{error}

} + + {detected === null && ( +

+ No Next.js App Router or Astro site found there. Nothing was changed — your + board still works through the tracker snippet, it just renders client-side. +

+ )} + + {detected && ( +

+ Found {FRAMEWORK_LABEL[detected.framework]} from{" "} + {detected.evidence}. The PR will add the + page under {detected.dir}. +

+ )} + + {result && ( +

+ {result.detail}{" "} + {result.prUrl && ( + + Review the pull request + + )} +

+ )} + +
+ + +
+
+ )} +
+ ); +} diff --git a/app/(app)/projects/[id]/stats/careers/page.tsx b/app/(app)/projects/[id]/stats/careers/page.tsx index 6fa112c0..56076271 100644 --- a/app/(app)/projects/[id]/stats/careers/page.tsx +++ b/app/(app)/projects/[id]/stats/careers/page.tsx @@ -2,8 +2,10 @@ import { notFound } from "next/navigation"; import { createClient } from "@/lib/supabase/server"; import { env } from "@/lib/env"; import type { ApplicationStatus, JobStatus, Workplace } from "@/lib/careers/jobs"; +import { loadRepoOptions } from "@/lib/github/repo-options"; import { StatsSubnav } from "../stats-subnav"; import { CareersToggle } from "../careers-toggle"; +import { CareersInstall } from "./careers-install"; import { JobManager, type JobRow } from "./job-manager"; import { Applications, type ApplicationRow } from "./applications"; @@ -91,6 +93,7 @@ export default async function CareersPage({ const trackerEnabled = Boolean(project.tracker_enabled); const careersEnabled = Boolean(project.careers_enabled); const hostedBoardUrl = `${env.siteUrl.replace(/\/+$/, "")}/c/${id}`; + const repoOptions = await loadRepoOptions(id); return (
@@ -151,6 +154,13 @@ export default async function CareersPage({

)} + + diff --git a/app/api/careers/jobs/route.ts b/app/api/careers/jobs/route.ts index 77b6aa9f..734740ce 100644 --- a/app/api/careers/jobs/route.ts +++ b/app/api/careers/jobs/route.ts @@ -8,7 +8,12 @@ import { NextRequest, NextResponse } from "next/server"; import { serviceClient } from "@/lib/supabase/service"; import { env } from "@/lib/env"; -import { hostedJobUrl, schemaEmploymentType, type PublicJob } from "@/lib/careers/jobs"; +import { + hostedJobUrl, + jobPostingJsonLd, + schemaEmploymentType, + type PublicJob, +} from "@/lib/careers/jobs"; export const runtime = "nodejs"; @@ -62,6 +67,16 @@ export async function GET(request: NextRequest) { // then have to keep in sync with ours. employment_type_schema: schemaEmploymentType(job.employment_type), canonical_url: hostedJobUrl(env.siteUrl, site, job.slug), + // Same reasoning, one level up: the SSR /careers page we generate for a + // customer's repo can't import our schema.org helpers, so it gets the + // finished graph rather than a second implementation to drift from. + json_ld: jobPostingJsonLd({ + job, + siteUrl: env.siteUrl, + projectId: site, + projectName: project.name ?? "", + projectUrl: project.url ?? "", + }), })); return NextResponse.json( diff --git a/app/api/projects/[id]/github/install-careers/route.ts b/app/api/projects/[id]/github/install-careers/route.ts new file mode 100644 index 00000000..66489492 --- /dev/null +++ b/app/api/projects/[id]/github/install-careers/route.ts @@ -0,0 +1,156 @@ +// POST /api/projects/[id]/github/install-careers +// Body: { owner, repo, installation_id, root_path?, default_branch?, mode? } +// +// mode=detect → report the framework we'd write for, without touching the repo. +// mode=submit → open the PR (default). +// +// Auth mirrors install-tracker: a signed-in user, with access to the project, +// using an installation connected to their own account. + +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { createClient } from "@/lib/supabase/server"; +import { serviceClient } from "@/lib/supabase/service"; +import { requireProjectAccess } from "@/lib/lx/currentSite"; +import { getOrMintInstallationToken } from "@/lib/github/installations"; +import { detectFramework, installCareersPage } from "@/lib/github/install-careers"; +import { getRepo } from "@/lib/github/repos"; + +export const runtime = "nodejs"; + +const bodySchema = z.object({ + owner: z.string().min(1), + repo: z.string().min(1), + installation_id: z.number().int().positive(), + root_path: z.string().max(500).optional(), + default_branch: z.string().max(200).optional(), + mode: z.enum(["detect", "submit"]).optional(), +}); + +export async function POST( + request: NextRequest, + ctx: { params: Promise<{ id: string }> }, +) { + const { id: projectId } = await ctx.params; + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + let body; + try { + body = bodySchema.parse(await request.json()); + } catch { + return NextResponse.json({ error: "Bad request" }, { status: 400 }); + } + + const projectAccess = await requireProjectAccess(projectId); + if (!projectAccess.ok) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + + const { data: installation } = await supabase + .from("github_installations") + .select("installation_id") + .eq("installation_id", body.installation_id) + .eq("user_id", user.id) + .maybeSingle(); + if (!installation) { + return NextResponse.json( + { error: "Installation not connected to this account" }, + { status: 403 }, + ); + } + + // The careers module has to be on: a PR pointing at a feed that returns + // nothing is a confusing thing to receive. + const { data: project } = await supabase + .from("projects") + .select("careers_enabled") + .eq("id", projectId) + .maybeSingle(); + if (!project?.careers_enabled) { + return NextResponse.json( + { error: "Turn the careers widget on before installing the page." }, + { status: 409 }, + ); + } + + const mode = body.mode ?? "submit"; + + if (mode === "detect") { + try { + const token = await getOrMintInstallationToken(body.installation_id); + const repoMeta = await getRepo({ token, owner: body.owner, repo: body.repo }); + const detected = await detectFramework({ + token, + owner: body.owner, + repo: body.repo, + ref: repoMeta.default_branch, + rootPath: body.root_path, + }); + return NextResponse.json({ data: { detected } }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return NextResponse.json({ error: msg }, { status: 500 }); + } + } + + const svc = serviceClient(); + const { data: run } = await (svc as any) + .from("project_pr_runs") + .insert({ + project_id: projectId, + owner_id: user.id, + kind: "install_careers", + installation_id: body.installation_id, + repo_owner: body.owner, + repo_name: body.repo, + status: "running", + }) + .select("id") + .single(); + const runId = run?.id as string | undefined; + + async function finalize(patch: Record) { + if (!runId) return; + await (svc as any) + .from("project_pr_runs") + .update({ ...patch, updated_at: new Date().toISOString() }) + .eq("id", runId); + } + + try { + const token = await getOrMintInstallationToken(body.installation_id); + const result = await installCareersPage({ + token, + owner: body.owner, + repo: body.repo, + projectId, + rootPath: body.root_path, + }); + await finalize({ + status: result.status, + pr_url: result.prUrl ?? null, + pr_number: result.prNumber ?? null, + branch_name: result.branch ?? null, + }); + await (svc as any).from("project_repos").upsert( + { + project_id: projectId, + installation_id: body.installation_id, + repo_owner: body.owner, + repo_name: body.repo, + default_branch: body.default_branch ?? null, + added_by: user.id, + }, + { onConflict: "project_id,repo_owner,repo_name" }, + ); + return NextResponse.json({ data: result }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + await finalize({ status: "failed", error: msg }); + return NextResponse.json({ error: msg }, { status: 500 }); + } +} diff --git a/app/careers.js/route.ts b/app/careers.js/route.ts index f063b4d3..455c677d 100644 --- a/app/careers.js/route.ts +++ b/app/careers.js/route.ts @@ -179,7 +179,7 @@ const snippet = `(function(){ return html; } - function render(root, payload) { + function render(root, payload, seeded) { var jobs = payload.jobs; var departments = []; for (var i = 0; i < jobs.length; i++) { @@ -294,6 +294,10 @@ const snippet = `(function(){ }); }); + // A server-rendered board (the /careers page our GitHub installer writes) + // seeds the container with the same roles as HTML. Replace it rather than + // appending, or the visitor sees every job twice. + if (seeded) root.innerHTML = ''; root.appendChild(wrap); paint(); window.addEventListener('hashchange', openFromHash); @@ -304,11 +308,15 @@ const snippet = `(function(){ .then(function(r){ return r.json(); }) .then(function(payload){ if (!payload || !payload.jobs) return; - injectJsonLd(payload); + var root = mountPoint(); + // When the page was server-rendered by our installer it already + // carries the JobPosting graph in the HTML source. A second copy + // would show crawlers every role twice, so let the markup win. + var seeded = !!(root && root.hasAttribute && root.hasAttribute('data-cp-careers-ssr')); + if (!seeded) injectJsonLd(payload); if (!payload.jobs.length) return; injectStyle(); - var root = mountPoint(); - if (root) render(root, payload); + if (root) render(root, payload, seeded); }) .catch(function(){}); } diff --git a/lib/careers/page-templates.ts b/lib/careers/page-templates.ts new file mode 100644 index 00000000..8ef9c5e9 --- /dev/null +++ b/lib/careers/page-templates.ts @@ -0,0 +1,254 @@ +// Source for the /careers page CrawlProof opens as a pull request. +// +// The careers widget already paints a board client-side, which is fine for +// people and useless for crawlers — a client-rendered job board is exactly the +// failure CrawlProof audits for. This module generates a real server-rendered +// route for the customer's own repo so the roles ship as HTML on their domain, +// with the JobPosting graph in the source rather than injected after load. +// +// Everything here is a pure string builder: no network, no repo access. That +// keeps the interesting part — what we are about to write into someone else's +// codebase — cheap to test exactly. +// +// The generated file is standalone by design. It cannot import from us, so the +// small amount of rendering logic below is duplicated into the output rather +// than shared. To stop that duplicate drifting, the generated code asks the +// feed for `json_ld` and `canonical_url` instead of recomputing schema.org +// itself; the only thing it owns is markup. + +export type CareersFramework = "next-app" | "astro"; + +export interface GeneratedFile { + path: string; + content: string; +} + +export interface TemplateInput { + /** CrawlProof's origin, e.g. https://crawlproof.com */ + origin: string; + projectId: string; + /** Directory the route goes in, e.g. "app", "src/app", "src/pages". */ + dir: string; + /** Emit type annotations. False for JavaScript repos, where they'd break the build. */ + typescript: boolean; +} + +const JOB_FIELDS = [ + "slug", + "title", + "department", + "location", + "employment_type", + "workplace", + "compensation", + "overview", + "responsibilities", + "qualifications", + "canonical_url", + "json_ld", +] as const; + +function feedUrl(origin: string, projectId: string): string { + return `${origin.replace(/\/+$/, "")}/api/careers/jobs?site=${projectId}`; +} + +function dashboardUrl(origin: string, projectId: string): string { + return `${origin.replace(/\/+$/, "")}/projects/${projectId}/stats/careers`; +} + +/** The banner every generated file opens with. */ +function header(origin: string, projectId: string): string { + return [ + "// Careers page generated by CrawlProof.", + "//", + "// Roles are managed in the CrawlProof dashboard, not in this file:", + `// ${dashboardUrl(origin, projectId)}`, + "//", + "// This page server-renders the open roles so search engines and answer", + "// engines can read them. If the CrawlProof tracker is installed, its", + "// widget upgrades the same markup into an inline application form.", + "//", + "// Safe to restyle or restructure. Re-running the installer will not", + "// overwrite it.", + ].join("\n"); +} + +const JOB_TYPE = `type CareersJob = { +${JOB_FIELDS.map((field) => { + if (field === "responsibilities" || field === "qualifications") return ` ${field}: string[];`; + if (field === "json_ld") return ` ${field}: Record;`; + if (field === "department" || field === "location" || field === "compensation" || field === "overview") { + return ` ${field}: string | null;`; + } + return ` ${field}: string;`; +}).join("\n")} +};`; + +/** + * The markup builders shared by every framework we support. + * + * Written as string concatenation rather than template literals so the output + * is easy to read in a diff and impossible to break by interpolation. + * + * Exported so the tests can execute the untyped build and prove the escaping + * holds, rather than only asserting the text looks right. + */ +export function renderHelpers(typescript: boolean): string { + const t = (annotation: string) => (typescript ? annotation : ""); + const job = t(": CareersJob"); + const str = t(": string"); + + return `function esc(value${t(": unknown")})${str} { + return String(value == null ? "" : value) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +// A bare city already reads as on-site, so only remote and hybrid get labelled. +function placeLabel(job${job})${str} { + var at = (job.location || "").trim(); + if (job.workplace === "remote") return at ? "Remote \\u00b7 " + at : "Remote"; + if (job.workplace === "hybrid") return at ? "Hybrid \\u00b7 " + at : "Hybrid"; + return at || "On-site"; +} + +function bullets(items${t(": string[]")}, heading${str})${str} { + if (!items || items.length === 0) return ""; + return ( + "

" + esc(heading) + "

    " + + items.map(function (item${str}) { return "
  • " + esc(item) + "
  • "; }).join("") + + "
" + ); +} + +function jobCard(job${job})${str} { + var meta = [placeLabel(job), job.department, job.employment_type, job.compensation] + .filter(Boolean) + .map(function (part) { return esc(part); }) + .join(" · "); + return [ + '
', + "

" + esc(job.title) + "

", + '

' + meta + "

", + job.overview ? "

" + esc(job.overview) + "

" : "", + bullets(job.responsibilities, "Responsibilities"), + bullets(job.qualifications, "Qualifications"), + '

Apply for ' + + esc(job.title) + "

", + "
", + ].join(""); +} + +// The JobPosting graph comes from the feed already built, so this file never +// has to keep a copy of the schema.org rules in sync. +function jsonLd(job${job})${str} { + if (!job.json_ld) return ""; + var payload = JSON.stringify(job.json_ld).replace(/' + payload + ""; +} + +function boardHtml(jobs${t(": CareersJob[]")})${str} { + if (!jobs || jobs.length === 0) { + return '

No open roles right now.

'; + } + return jobs.map(jobCard).join("") + jobs.map(jsonLd).join(""); +}`; +} + +/** The fetch, written once. Failure renders an empty board, never an error page. */ +function loader(typescript: boolean, options: string): string { + const t = (annotation: string) => (typescript ? annotation : ""); + return `async function loadJobs()${t(": Promise")} { + try { + var res = await fetch(FEED${options}); + if (!res.ok) return []; + var payload = await res.json(); + return (payload && payload.jobs) || []; + } catch { + // A careers page that 500s because our API blinked is worse than one + // that shows no roles for a few minutes. + return []; + } +}`; +} + +function nextAppPage(input: TemplateInput): GeneratedFile[] { + const ext = input.typescript ? "tsx" : "jsx"; + const content = `${header(input.origin, input.projectId)} + +export const revalidate = 300; + +const FEED = ${JSON.stringify(feedUrl(input.origin, input.projectId))}; + +${input.typescript ? `${JOB_TYPE}\n\n` : ""}${renderHelpers(input.typescript)} + +${loader(input.typescript, ", { next: { revalidate: revalidate } }")} + +export default async function CareersPage() { + const jobs = await loadJobs(); + return ( +
+

Open roles

+
+
+ ); +} +`; + return [{ path: `${input.dir}/careers/page.${ext}`, content }]; +} + +function astroPage(input: TemplateInput): GeneratedFile[] { + const content = `--- +${header(input.origin, input.projectId)} +// +// On a statically built Astro site this runs at build time, so new roles +// appear on your next deploy. Switch the route to on-demand rendering if you +// want them to appear immediately. + +const FEED = ${JSON.stringify(feedUrl(input.origin, input.projectId))}; + +${JOB_TYPE} + +${renderHelpers(true)} + +${loader(true, "")} + +const jobs = await loadJobs(); +const html = boardHtml(jobs); +--- + +
+

Open roles

+
+
+`; + return [{ path: `${input.dir}/careers.astro`, content }]; +} + +/** + * The route file(s) to write for a framework. + * + * Astro frontmatter is always TypeScript, so the `typescript` flag only + * changes the Next output. + */ +export function careersRouteFiles( + framework: CareersFramework, + input: TemplateInput, +): GeneratedFile[] { + return framework === "astro" ? astroPage(input) : nextAppPage(input); +} + +/** Paths that would collide with the route we want to add. */ +export function conflictingPaths(framework: CareersFramework, dir: string): string[] { + if (framework === "astro") { + return [`${dir}/careers.astro`, `${dir}/careers/index.astro`]; + } + return ["tsx", "jsx", "ts", "js"].map((ext) => `${dir}/careers/page.${ext}`); +} diff --git a/lib/github/install-careers.ts b/lib/github/install-careers.ts new file mode 100644 index 00000000..20b69dfa --- /dev/null +++ b/lib/github/install-careers.ts @@ -0,0 +1,226 @@ +// Open a pull request adding a real server-rendered /careers page to a +// customer's repo. +// +// This is the counterpart to the drop-in widget, not a replacement for it. The +// widget needs no install and paints the board client-side; that is invisible +// to crawlers, so a job board that exists only as JavaScript will not be read +// by search or answer engines. This installer writes an actual route so the +// roles ship as HTML on the customer's own domain, and the widget then upgrades +// that same markup into the inline application form. +// +// Deterministic, like install-tracker and install-ad: no LLM, no guessing. We +// add a file only where we can positively identify the framework and the route +// directory. Every other repo gets an honest no-op explaining that the widget +// already covers them — writing a speculative page into someone's repo is worse +// than not opening a PR at all. + +import { env } from "@/lib/env"; +import { + careersRouteFiles, + conflictingPaths, + type CareersFramework, +} from "@/lib/careers/page-templates"; +import { createBranch, getFileContent, getRepo, openPullRequest, putFile } from "./repos"; + +const ORIGIN = env.siteUrl.replace(/\/+$/, ""); +const BRANCH_PREFIX = "crawlproof/careers-page"; + +export interface DetectedFramework { + framework: CareersFramework; + /** Directory the route belongs in, repo-relative. */ + dir: string; + typescript: boolean; + /** The file that gave the framework away, for the PR body. */ + evidence: string; +} + +export interface InstallCareersInput { + token: string; + owner: string; + repo: string; + projectId: string; + /** Subdirectory inside the repo where the site lives, e.g. "apps/web". */ + rootPath?: string; + /** Skip detection; used by tests and by callers that already probed. */ + detected?: DetectedFramework; +} + +export interface InstallCareersResult { + status: "opened" | "noop"; + prUrl?: string; + prNumber?: number; + branch?: string; + framework?: CareersFramework; + /** Files the PR adds. */ + paths?: string[]; + detail: string; +} + +function joinPath(root: string, rest: string): string { + const base = root.replace(/^\/+/, "").replace(/\/+$/, ""); + return base ? `${base}/${rest}` : rest; +} + +/** + * Identify the framework by looking for files only that framework has. + * + * Astro is checked first: an Astro repo has no app/layout.tsx, so the order + * only matters for the mixed monorepo case, where the config file at the given + * root is the better signal. + */ +export async function detectFramework(input: { + token: string; + owner: string; + repo: string; + ref: string; + rootPath?: string; +}): Promise { + const root = input.rootPath ?? ""; + const probe = async (path: string) => + getFileContent({ + token: input.token, + owner: input.owner, + repo: input.repo, + path: joinPath(root, path), + ref: input.ref, + }); + + for (const config of ["astro.config.mjs", "astro.config.ts", "astro.config.js", "astro.config.cjs"]) { + if (await probe(config)) { + return { + framework: "astro", + dir: joinPath(root, "src/pages"), + typescript: true, // Astro frontmatter is TypeScript regardless. + evidence: joinPath(root, config), + }; + } + } + + // App Router only. The Pages Router and the older getStaticProps shapes are + // different enough that a shared template would be a guess. + const layouts: Array<[string, boolean]> = [ + ["app/layout.tsx", true], + ["app/layout.jsx", false], + ["app/layout.js", false], + ["src/app/layout.tsx", true], + ["src/app/layout.jsx", false], + ["src/app/layout.js", false], + ]; + for (const [path, typescript] of layouts) { + if (await probe(path)) { + return { + framework: "next-app", + dir: joinPath(root, path.replace(/\/layout\.[a-z]+$/, "")), + typescript, + evidence: joinPath(root, path), + }; + } + } + + return null; +} + +const UNSUPPORTED = + "Could not find a Next.js App Router or Astro site in this repo, so nothing was changed. " + + "Your careers board still works through the tracker snippet — it just renders client-side."; + +export async function installCareersPage( + input: InstallCareersInput, +): Promise { + const repoMeta = await getRepo({ token: input.token, owner: input.owner, repo: input.repo }); + const base = repoMeta.default_branch; + + const detected = + input.detected ?? + (await detectFramework({ + token: input.token, + owner: input.owner, + repo: input.repo, + ref: base, + rootPath: input.rootPath, + })); + if (!detected) return { status: "noop", detail: UNSUPPORTED }; + + // Never overwrite a careers page they already have — theirs may be hand + // written, and a PR that clobbers it is a PR that gets us uninstalled. + for (const path of conflictingPaths(detected.framework, detected.dir)) { + const existing = await getFileContent({ + token: input.token, + owner: input.owner, + repo: input.repo, + path, + ref: base, + }); + if (existing) { + return { + status: "noop", + framework: detected.framework, + paths: [path], + detail: `This repo already has a careers page at ${path}; left untouched.`, + }; + } + } + + const files = careersRouteFiles(detected.framework, { + origin: ORIGIN, + projectId: input.projectId, + dir: detected.dir, + typescript: detected.typescript, + }); + + const branch = `${BRANCH_PREFIX}-${input.projectId.slice(0, 8)}-${Date.now().toString(36)}`; + await createBranch({ + token: input.token, + owner: input.owner, + repo: input.repo, + newBranch: branch, + fromBranch: base, + }); + + for (const file of files) { + await putFile({ + token: input.token, + owner: input.owner, + repo: input.repo, + path: file.path, + branch, + message: "Add server-rendered careers page", + contentUtf8: file.content, + }); + } + + const paths = files.map((f) => f.path); + const pr = await openPullRequest({ + token: input.token, + owner: input.owner, + repo: input.repo, + head: branch, + base, + title: "Add a server-rendered careers page", + body: [ + "This PR adds a `/careers` page that renders your open roles as HTML on your own domain.", + "", + `- Detected **${detected.framework === "astro" ? "Astro" : "Next.js (App Router)"}** from \`${detected.evidence}\`.`, + `- Added ${paths.map((p) => `\`${p}\``).join(", ")}.`, + detected.framework === "astro" + ? "- Roles are fetched from CrawlProof when the page renders. On a statically built Astro site that means at build time, so redeploy (or switch this route to on-demand rendering) to pick up new roles." + : "- Roles are fetched from CrawlProof at request time and cached for 5 minutes. Edit them in the dashboard, not in this file.", + "- Each role ships with `JobPosting` structured data in the server HTML, which is what Google for Jobs and answer engines read.", + "", + "The page is deliberately unstyled — it inherits your site's CSS and is yours to restyle.", + "If the CrawlProof tracker is installed, its widget replaces the rendered list with an inline application form; without it, each role links to its hosted application page.", + "", + `Manage roles at ${ORIGIN}/projects/${input.projectId}/stats/careers`, + ].join("\n"), + }); + + return { + status: "opened", + prUrl: pr.html_url, + prNumber: pr.number, + branch, + framework: detected.framework, + paths, + detail: `Opened PR #${pr.number} adding ${paths.join(", ")}.`, + }; +} diff --git a/lib/github/repo-options.ts b/lib/github/repo-options.ts new file mode 100644 index 00000000..63440acd --- /dev/null +++ b/lib/github/repo-options.ts @@ -0,0 +1,76 @@ +// Repos a project can open a PR against, for the dashboard's install buttons. +// +// Best-effort throughout: if the GitHub App isn't configured, or an +// installation's listing fails, the caller gets fewer options rather than an +// error page. The install button simply doesn't appear. + +import { env } from "@/lib/env"; +import { createClient } from "@/lib/supabase/server"; +import { getOrMintInstallationToken } from "@/lib/github/installations"; +import { listInstallationRepos } from "@/lib/github/app"; + +export interface RepoOption { + full_name: string; + installation_id: number; + default_branch?: string | null; + /** Already attached to this project — worth showing first. */ + bound?: boolean; +} + +export interface RepoOptions { + /** False when the GitHub App isn't set up for this deployment. */ + configured: boolean; + repos: RepoOption[]; +} + +export async function loadRepoOptions(projectId: string): Promise { + const configured = !!(env.githubAppId && env.githubAppPrivateKey); + if (!configured) return { configured: false, repos: [] }; + + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) return { configured: true, repos: [] }; + + const { data: installRows } = await supabase + .from("github_installations") + .select("installation_id") + .is("removed_at", null); + + const { data: boundRows } = await supabase + .from("project_repos") + .select("repo_owner, repo_name") + .eq("project_id", projectId); + const bound = new Set( + ((boundRows ?? []) as Array<{ repo_owner: string; repo_name: string }>).map( + (r) => `${r.repo_owner}/${r.repo_name}`, + ), + ); + + const repos: RepoOption[] = []; + for (const row of (installRows ?? []) as Array<{ installation_id: number }>) { + try { + const token = await getOrMintInstallationToken(row.installation_id); + for (const repo of await listInstallationRepos(token)) { + repos.push({ + full_name: repo.full_name, + installation_id: row.installation_id, + default_branch: repo.default_branch, + bound: bound.has(repo.full_name), + }); + } + } catch { + // Skip this installation; the integrations settings page reports why. + } + } + + // Connected repos first, then alphabetical — the one they want is usually + // one they've already attached. + repos.sort((a, b) => { + if (a.bound !== b.bound) return a.bound ? -1 : 1; + return a.full_name.localeCompare(b.full_name); + }); + + return { configured: true, repos }; +} diff --git a/supabase/migrations/20260804120000_pr_runs_install_careers.sql b/supabase/migrations/20260804120000_pr_runs_install_careers.sql new file mode 100644 index 00000000..7be530d2 --- /dev/null +++ b/supabase/migrations/20260804120000_pr_runs_install_careers.sql @@ -0,0 +1,7 @@ +-- The careers page installer records its runs in project_pr_runs like every +-- other PR-opening action. Widen the kind check to allow 'install_careers'. +alter table public.project_pr_runs + drop constraint if exists project_pr_runs_kind_check; +alter table public.project_pr_runs + add constraint project_pr_runs_kind_check + check (kind in ('install_tracker', 'apply_fix', 'audience_hub', 'install_ad', 'install_careers')); diff --git a/tests/careers-install-pr.test.ts b/tests/careers-install-pr.test.ts new file mode 100644 index 00000000..edf70c01 --- /dev/null +++ b/tests/careers-install-pr.test.ts @@ -0,0 +1,205 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// The installer writes to a customer's repository, so the rules that matter +// most are the ones about NOT writing: no framework, or a careers page that +// already exists, must leave the repo untouched. + +const github = vi.hoisted(() => { + const files = new Map(); + return { + files, + getRepo: vi.fn(async () => ({ + default_branch: "main", + full_name: "owner/repo", + private: false, + id: 1, + })), + getFileContent: vi.fn(async ({ path }: { path: string }) => { + const content = files.get(path); + if (!content) return null; + return { path, sha: `sha-${path}`, content }; + }), + searchRepoCode: vi.fn(async () => []), + createBranch: vi.fn(async (_input: { newBranch: string; fromBranch: string }) => ({ + created: true, + })), + putFile: vi.fn(async ({ path }: { path: string; contentUtf8: string }) => ({ + content: { sha: `new-sha-${path}`, path }, + commit: { sha: "commit-sha" }, + })), + openPullRequest: vi.fn( + async (_input: { base: string; head: string; title: string; body: string }) => ({ + html_url: "https://github.test/owner/repo/pull/12", + number: 12, + state: "open", + }), + ), + }; +}); + +vi.mock("@/lib/github/repos", () => ({ + getRepo: github.getRepo, + getFileContent: github.getFileContent, + searchRepoCode: github.searchRepoCode, + createBranch: github.createBranch, + putFile: github.putFile, + openPullRequest: github.openPullRequest, +})); + +import { detectFramework, installCareersPage } from "@/lib/github/install-careers"; + +const BASE = { token: "token", owner: "owner", repo: "repo" }; +const PROJECT = "af9ab953-caa6-4a2b-a306-42fb4eac4630"; + +beforeEach(() => { + github.files.clear(); + for (const fn of [ + github.getRepo, + github.getFileContent, + github.createBranch, + github.putFile, + github.openPullRequest, + ]) { + fn.mockClear(); + } +}); + +describe("detectFramework", () => { + it("finds a Next App Router app and infers TypeScript from the layout", async () => { + github.files.set("app/layout.tsx", "export default function L() {}"); + const found = await detectFramework({ ...BASE, ref: "main" }); + expect(found).toMatchObject({ + framework: "next-app", + dir: "app", + typescript: true, + evidence: "app/layout.tsx", + }); + }); + + it("infers JavaScript from a .js layout", async () => { + github.files.set("src/app/layout.js", "export default function L() {}"); + const found = await detectFramework({ ...BASE, ref: "main" }); + expect(found).toMatchObject({ framework: "next-app", dir: "src/app", typescript: false }); + }); + + it("finds Astro from its config", async () => { + github.files.set("astro.config.mjs", "export default {};"); + const found = await detectFramework({ ...BASE, ref: "main" }); + expect(found).toMatchObject({ framework: "astro", dir: "src/pages" }); + }); + + it("scopes detection to a monorepo subdirectory", async () => { + github.files.set("apps/web/app/layout.tsx", "export default function L() {}"); + const found = await detectFramework({ ...BASE, ref: "main", rootPath: "apps/web" }); + expect(found).toMatchObject({ dir: "apps/web/app", evidence: "apps/web/app/layout.tsx" }); + }); + + it("returns null for a repo it doesn't recognise", async () => { + github.files.set("index.html", ""); + expect(await detectFramework({ ...BASE, ref: "main" })).toBeNull(); + }); +}); + +describe("installCareersPage", () => { + it("opens a PR adding the Next page", async () => { + github.files.set("app/layout.tsx", "export default function L() {}"); + + const result = await installCareersPage({ ...BASE, projectId: PROJECT }); + + expect(result.status).toBe("opened"); + expect(result.paths).toEqual(["app/careers/page.tsx"]); + expect(result.prUrl).toBe("https://github.test/owner/repo/pull/12"); + expect(github.putFile).toHaveBeenCalledTimes(1); + + const written = github.putFile.mock.calls[0][0] as unknown as { + path: string; + contentUtf8: string; + branch: string; + }; + expect(written.path).toBe("app/careers/page.tsx"); + expect(written.contentUtf8).toContain(`site=${PROJECT}`); + // New file: no sha, or GitHub rejects the write. + expect((written as { sha?: string }).sha).toBeUndefined(); + expect(written.branch).toMatch(/^crawlproof\/careers-page-/); + }); + + it("opens a PR adding the Astro page", async () => { + github.files.set("astro.config.ts", "export default {};"); + const result = await installCareersPage({ ...BASE, projectId: PROJECT }); + expect(result.status).toBe("opened"); + expect(result.paths).toEqual(["src/pages/careers.astro"]); + }); + + it("names the framework and the evidence in the PR body", async () => { + github.files.set("app/layout.tsx", "export default function L() {}"); + await installCareersPage({ ...BASE, projectId: PROJECT }); + const pr = github.openPullRequest.mock.calls[0][0] as unknown as { + body: string; + title: string; + }; + expect(pr.body).toContain("Next.js (App Router)"); + expect(pr.body).toContain("app/layout.tsx"); + expect(pr.body).toContain("JobPosting"); + expect(pr.title).toBe("Add a server-rendered careers page"); + }); + + it("tells an Astro user their roles update on deploy, not on request", async () => { + github.files.set("astro.config.mjs", "export default {};"); + await installCareersPage({ ...BASE, projectId: PROJECT }); + const pr = github.openPullRequest.mock.calls[0][0] as unknown as { body: string }; + expect(pr.body).toContain("build time"); + expect(pr.body).not.toContain("cached for 5 minutes"); + }); + + it("changes nothing when it can't identify the framework", async () => { + github.files.set("index.html", ""); + + const result = await installCareersPage({ ...BASE, projectId: PROJECT }); + + expect(result.status).toBe("noop"); + expect(result.detail).toContain("client-side"); + expect(github.createBranch).not.toHaveBeenCalled(); + expect(github.putFile).not.toHaveBeenCalled(); + expect(github.openPullRequest).not.toHaveBeenCalled(); + }); + + it("refuses to overwrite a careers page they already have", async () => { + github.files.set("app/layout.tsx", "export default function L() {}"); + github.files.set("app/careers/page.tsx", "export default function Mine() {}"); + + const result = await installCareersPage({ ...BASE, projectId: PROJECT }); + + expect(result.status).toBe("noop"); + expect(result.detail).toContain("already has a careers page"); + expect(github.putFile).not.toHaveBeenCalled(); + expect(github.openPullRequest).not.toHaveBeenCalled(); + }); + + it("spots an existing page written in another dialect", async () => { + github.files.set("app/layout.tsx", "export default function L() {}"); + github.files.set("app/careers/page.js", "module.exports = {};"); + + const result = await installCareersPage({ ...BASE, projectId: PROJECT }); + expect(result.status).toBe("noop"); + expect(github.putFile).not.toHaveBeenCalled(); + }); + + it("branches from the repo's own default branch", async () => { + github.getRepo.mockResolvedValueOnce({ + default_branch: "trunk", + full_name: "owner/repo", + private: false, + id: 1, + }); + github.files.set("app/layout.tsx", "export default function L() {}"); + + await installCareersPage({ ...BASE, projectId: PROJECT }); + + const branchCall = github.createBranch.mock.calls[0][0] as unknown as { + fromBranch: string; + }; + expect(branchCall.fromBranch).toBe("trunk"); + const pr = github.openPullRequest.mock.calls[0][0] as unknown as { base: string }; + expect(pr.base).toBe("trunk"); + }); +}); diff --git a/tests/careers-page-templates.test.ts b/tests/careers-page-templates.test.ts new file mode 100644 index 00000000..3554ad01 --- /dev/null +++ b/tests/careers-page-templates.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, it } from "vitest"; +import { + careersRouteFiles, + conflictingPaths, + renderHelpers, +} from "@/lib/careers/page-templates"; + +// This module writes code into other people's repositories, so the tests do +// two things: check the file we produce is the right shape, and actually run +// the generated rendering logic against hostile input. + +const INPUT = { + origin: "https://crawlproof.com", + projectId: "af9ab953-caa6-4a2b-a306-42fb4eac4630", + dir: "app", + typescript: true, +}; + +/** Execute the generated helpers so we can assert on real output. */ +function runtime() { + const factory = new Function( + `${renderHelpers(false)} + return { esc: esc, placeLabel: placeLabel, jobCard: jobCard, boardHtml: boardHtml };`, + ); + return factory() as { + esc: (v: unknown) => string; + placeLabel: (job: Record) => string; + jobCard: (job: Record) => string; + boardHtml: (jobs: Record[]) => string; + }; +} + +function job(overrides: Record = {}) { + return { + slug: "hpc-engineer", + title: "HPC Engineer", + department: "Infrastructure", + location: "Austin, TX", + employment_type: "Full-time", + workplace: "onsite", + compensation: "$180k–$220k", + overview: "Own the cluster.", + responsibilities: ["Keep it fast"], + qualifications: ["Know Linux"], + canonical_url: "https://crawlproof.com/c/proj/hpc-engineer", + json_ld: { "@type": "JobPosting", title: "HPC Engineer" }, + ...overrides, + }; +} + +describe("generated file shape", () => { + it("writes a Next App Router page with a typed job shape", () => { + const [file] = careersRouteFiles("next-app", INPUT); + expect(file.path).toBe("app/careers/page.tsx"); + expect(file.content).toContain("type CareersJob"); + expect(file.content).toContain("export const revalidate = 300"); + expect(file.content).toContain("dangerouslySetInnerHTML"); + expect(file.content).toContain( + "https://crawlproof.com/api/careers/jobs?site=af9ab953-caa6-4a2b-a306-42fb4eac4630", + ); + }); + + // Type annotations in a JavaScript repo would break their build outright. + it("emits plain JavaScript, with no annotations, for a JS repo", () => { + const [file] = careersRouteFiles("next-app", { ...INPUT, typescript: false }); + expect(file.path).toBe("app/careers/page.jsx"); + expect(file.content).not.toContain("type CareersJob"); + expect(file.content).not.toContain(": string"); + expect(file.content).not.toContain("Promise<"); + }); + + it("honours a src/ layout", () => { + const [file] = careersRouteFiles("next-app", { ...INPUT, dir: "src/app" }); + expect(file.path).toBe("src/app/careers/page.tsx"); + }); + + it("writes an Astro page with frontmatter and set:html", () => { + const [file] = careersRouteFiles("astro", { ...INPUT, dir: "src/pages" }); + expect(file.path).toBe("src/pages/careers.astro"); + expect(file.content.startsWith("---\n")).toBe(true); + expect(file.content).toContain("set:html={html}"); + // Astro frontmatter is TypeScript, so the types stay regardless. + expect(file.content).toContain("type CareersJob"); + }); + + // A static Astro build bakes the roles in, and saying otherwise would have + // people wondering why a new job never showed up. + it("warns that a static Astro build only updates on deploy", () => { + const [file] = careersRouteFiles("astro", { ...INPUT, dir: "src/pages" }); + expect(file.content).toContain("build time"); + expect(careersRouteFiles("next-app", INPUT)[0].content).not.toContain("build time"); + }); + + it("marks the container so the widget replaces rather than duplicates", () => { + for (const framework of ["next-app", "astro"] as const) { + const [file] = careersRouteFiles(framework, INPUT); + expect(file.content).toContain("data-cp-careers"); + expect(file.content).toContain("data-cp-careers-ssr"); + } + }); + + it("points the reader at the dashboard rather than the file", () => { + const [file] = careersRouteFiles("next-app", INPUT); + expect(file.content).toContain( + "https://crawlproof.com/projects/af9ab953-caa6-4a2b-a306-42fb4eac4630/stats/careers", + ); + }); + + it("survives an origin with a trailing slash", () => { + const [file] = careersRouteFiles("next-app", { + ...INPUT, + origin: "https://crawlproof.com/", + }); + expect(file.content).toContain("https://crawlproof.com/api/careers/jobs?site="); + expect(file.content).not.toContain("crawlproof.com//api"); + }); + + it("never returns an empty file set", () => { + for (const framework of ["next-app", "astro"] as const) { + const files = careersRouteFiles(framework, INPUT); + expect(files.length).toBeGreaterThan(0); + for (const f of files) expect(f.content.length).toBeGreaterThan(0); + } + }); +}); + +describe("conflictingPaths", () => { + it("covers every extension a Next page could already use", () => { + expect(conflictingPaths("next-app", "app")).toEqual([ + "app/careers/page.tsx", + "app/careers/page.jsx", + "app/careers/page.ts", + "app/careers/page.js", + ]); + }); + + it("covers both Astro spellings", () => { + expect(conflictingPaths("astro", "src/pages")).toContain("src/pages/careers.astro"); + expect(conflictingPaths("astro", "src/pages")).toContain("src/pages/careers/index.astro"); + }); +}); + +describe("generated rendering, executed", () => { + it("escapes hostile job content instead of emitting it", () => { + const { jobCard } = runtime(); + const html = jobCard( + job({ + title: '', + overview: 'He said "hi" & left', + location: "", + }), + ); + expect(html).not.toContain("" } }), + ]); + expect(html).not.toContain(" { + const { boardHtml } = runtime(); + expect(boardHtml([])).toContain("No open roles"); + }); + + it("omits empty sections rather than printing bare headings", () => { + const { jobCard } = runtime(); + const html = jobCard(job({ responsibilities: [], qualifications: [], overview: null })); + expect(html).not.toContain("Responsibilities"); + expect(html).not.toContain("Qualifications"); + expect(html).toContain("HPC Engineer"); + }); +});