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"); + }); +});