Skip to content

Careers: open a PR adding a server-rendered /careers page - #188

Merged
ralyodio merged 1 commit into
masterfrom
feature/careers-pr
Aug 4, 2026
Merged

ralyodio merged 1 commit into
masterfrom
feature/careers-pr

Conversation

@ralyodio

@ralyodio ralyodio commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Adds PR support to the careers dashboard — the crawlable counterpart to the drop-in widget.

Why

The widget paints the job board client-side. That's fine for people and useless for crawlers, which is exactly the failure CrawlProof audits for elsewhere. Google for Jobs will not index a board that exists only as JavaScript.

This puts 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 rather than injected after load. The widget then upgrades that same markup into the inline application form — crawlers get HTML, humans get the form, and the two say the same thing.

What it does

On Stats → Careers, a "Add to my repo" panel: pick a repo, Check repo (read-only detection, nothing is written), then Open pull request.

Deterministic, like install-tracker and install-ad — no LLM, no guessing:

  • Writes a file only where the framework is positively identified: Next.js App Router (app/ or src/app/, .tsx or .jsx) or Astro. Monorepo subdirectories supported.
  • Never overwrites an existing careers page, in any extension.
  • Everything else gets an honest no-op: "your board still works through the tracker snippet, it just renders client-side." Writing a speculative page into someone's repo is worse than opening no PR at all.

Notable decisions

The feed now returns the finished json_ld. The generated file can't import our helpers, so the alternative was a second copy of the schema.org rules drifting from the tested one. Same reasoning the feed already uses for employment_type_schema and canonical_url.

The widget learns about SSR boards. A container marked data-cp-careers-ssr gets replaced rather than appended to, and its JSON-LD is left alone — otherwise the visitor sees every job twice and the crawler reads a duplicate graph. The clear only ever fires on that attribute, never on the <main>/<body> fallback.

Astro's caching is described honestly. A statically built Astro site bakes the roles in at build time, so the PR body and the generated file both say roles appear on the next deploy, rather than repeating Next's "cached for 5 minutes".

Verification

  • tsc --noEmit clean; next build compiles; 1357 tests pass (31 new).
  • The generated page was emitted into this repo's own tsconfig and typechecked, proving it compiles in a strict Next + TypeScript project.
  • The generated rendering logic is executed in tests, not just string-matched — hostile titles, " in an href, and a </script> payload inside the JSON-LD.
  • Mutation-tested rather than trusting green: breaking the HTML escaping, the JSON-LD < guard, the overwrite protection, and the unknown-framework no-op each fails a test.

Migration

20260804120000_pr_runs_install_careers.sql widens the project_pr_runs.kind check to allow install_careers. Not yet applied — say the word.

Not covered

SvelteKit, Nuxt, and the Next Pages Router fall through to the no-op. Their templates are different enough that a shared one would be a guess; the widget still covers those sites client-side.

🤖 Generated with Claude Code

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 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

ThreatCrush Security Scan

54 finding(s)

HIGH/CRITICAL: 10 | MEDIUM: 44

Severity Rule Location
HIGH secret-generic-credential app/(marketing)/docs/autoblog-webhook/page.tsx:145
HIGH secret-generic-credential lib/sp/platforms/facebook.ts:32
HIGH js-ssrf-outbound-request lib/sp/platforms/facebook.ts:115
HIGH secret-generic-credential lib/sp/platforms/linkedin.ts:25
HIGH js-ssrf-outbound-request lib/sp/platforms/telegram.ts:63
HIGH js-ssrf-outbound-request lib/sp/platforms/threads.ts:138
HIGH manifest-typosquat package.json:59
HIGH secret-generic-credential tests/contract/coinpay.test.ts:4
HIGH secret-generic-credential tests/contract/posthog-integration.test.ts:13
HIGH secret-generic-credential tests/lead-campaign.test.ts:16
MEDIUM js-unescaped-html-sink app/(app)/admin/email-broadcast/EmailBroadcastForm.tsx:125
MEDIUM sql-template-interpolation app/(app)/projects/[id]/autoblog/actions.tsx:96
MEDIUM js-unescaped-html-sink app/(app)/projects/[id]/autoblog/articles/[articleId]/page.tsx:214
MEDIUM sql-template-interpolation app/(app)/projects/[id]/autoblog/setup/form.tsx:504
MEDIUM sql-template-interpolation app/(app)/projects/[id]/uptime/monitor-actions.tsx:28
MEDIUM js-unescaped-html-sink app/(marketing)/blog/[slug]/page.tsx:67
MEDIUM js-unescaped-html-sink app/(marketing)/blog/[slug]/page.tsx:97
MEDIUM js-unescaped-html-sink app/(marketing)/blog/[slug]/page.tsx:104
MEDIUM js-unescaped-html-sink app/(marketing)/blog/[slug]/page.tsx:110
MEDIUM js-unescaped-html-sink app/(marketing)/recent/page.tsx:186
MEDIUM js-unescaped-html-sink app/(marketing)/recent/page.tsx:190
MEDIUM sql-template-interpolation app/actions/admin.ts:114
MEDIUM sql-template-interpolation app/actions/orgs.ts:328
MEDIUM sql-template-interpolation app/api/lx/keywords/regenerate/route.ts:59
MEDIUM js-unescaped-html-sink app/c/[project]/[slug]/page.tsx:77
MEDIUM js-unescaped-html-sink app/c/[project]/page.tsx:57
MEDIUM js-unescaped-html-sink app/careers.js/route.ts:201
MEDIUM js-unescaped-html-sink app/careers.js/route.ts:228
MEDIUM js-unescaped-html-sink app/careers.js/route.ts:285
MEDIUM js-unescaped-html-sink app/layout.tsx:129
MEDIUM js-open-redirect app/login/form.tsx:39
MEDIUM js-unescaped-html-sink app/r/[token]/page.tsx:176
MEDIUM js-open-redirect app/signup/form.tsx:43
MEDIUM js-open-redirect components/billing/buy-credits-modal.tsx:98
MEDIUM js-unescaped-html-sink components/json-ld.tsx:8
MEDIUM js-unescaped-html-sink components/report/markdown-view.tsx:15
MEDIUM sql-template-interpolation lib/audit/checks/security.ts:48
MEDIUM redos-nested-quantifier lib/careers/jobs.ts:139
MEDIUM js-unescaped-html-sink lib/careers/page-templates.ts:198
MEDIUM redos-nested-quantifier lib/emailMarkdown.ts:130
MEDIUM redos-nested-quantifier lib/lx/articleGen.ts:93
MEDIUM sql-template-interpolation lib/lx/articleGen.ts:367
MEDIUM sql-template-interpolation lib/lx/articleGen.ts:379
MEDIUM sql-template-interpolation lib/lx/articleGen.ts:380
MEDIUM sql-template-interpolation lib/lx/articleGen.ts:1340
MEDIUM sql-template-interpolation lib/lx/articleGen.ts:1362
MEDIUM sql-template-interpolation lib/lx/guestPostGen.ts:109
MEDIUM tls-verification-disabled lib/onion.ts:47
MEDIUM sql-template-interpolation lib/sp/platforms/linkedin.ts:177
MEDIUM sql-template-interpolation scripts/delete-archived-projects.mjs:97

…and 4 more. Full results in the Security tab.

Snippets are redacted; ThreatCrush never prints matched credential material.

@ralyodio
ralyodio marked this pull request as ready for review August 4, 2026 04:18
@ralyodio
ralyodio merged commit a0ef66b into master Aug 4, 2026
8 checks passed
@ralyodio
ralyodio deleted the feature/careers-pr branch August 4, 2026 04:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant