Monorepo for Tangled tangled.org
6

Configure Feed

Select the types of activity you want to include in your feed.

1import { marked } from 'marked'; 2import { getEmojiFlag, countries } from 'countries-list'; 3 4export interface Env { 5 LINEAR_API_KEY: string; 6 LINEAR_TEAM_ID: string; 7} 8 9// --------------------------------------------------------------------------- 10// Job postings — add a new markdown file in src/postings/ and import it here 11// --------------------------------------------------------------------------- 12// import softwareEngineer from './postings/software-engineer.md'; 13 14interface Posting { 15 slug: string; 16 title: string; 17 location: string; 18 type: string; 19 salary: string; 20 body: string; // raw markdown (frontmatter stripped) 21} 22 23function parsePosting(slug: string, raw: string): Posting { 24 const fm = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/); 25 if (!fm) throw new Error(`Posting ${slug} is missing frontmatter`); 26 27 const meta: Record<string, string> = {}; 28 for (const line of fm[1].split('\n')) { 29 const [k, ...rest] = line.split(':'); 30 if (k && rest.length) meta[k.trim()] = rest.join(':').trim(); 31 } 32 33 return { 34 slug, 35 title: meta['title'] ?? slug, 36 location: meta['location'] ?? 'Remote', 37 type: meta['type'] ?? 'Full-time', 38 salary: meta['salary'] ?? '', 39 body: fm[2].trim(), 40 }; 41} 42 43const POSTINGS: Posting[] = [ 44 // parsePosting('software-engineer', softwareEngineer as string), 45 // add more here as you create markdown files 46]; 47 48const POSTINGS_BY_SLUG = new Map(POSTINGS.map((p) => [p.slug, p])); 49 50// --------------------------------------------------------------------------- 51// Country list (sorted alphabetically by name, from countries-list) 52// --------------------------------------------------------------------------- 53const COUNTRIES: { code: string; name: string }[] = Object.entries(countries) 54 .map(([code, c]) => ({ code, name: c.name })) 55 .sort((a, b) => a.name.localeCompare(b.name)); 56 57// --------------------------------------------------------------------------- 58// SVG logo 59// --------------------------------------------------------------------------- 60const DOLLY_SVG = `<svg class="size-7" width="25" height="25" viewBox="0 0 25 25" xmlns="http://www.w3.org/2000/svg"> 61 <style>.dolly{color:#000}@media(prefers-color-scheme:dark){.dolly{color:#fff}}</style> 62 <g transform="translate(-0.42924038,-0.87777209)"> 63 <path class="dolly" fill="currentColor" style="stroke-width:0.111183" d="m 16.775491,24.987061 c -0.78517,-0.0064 -1.384202,-0.234614 -2.033994,-0.631295 -0.931792,-0.490188 -1.643475,-1.31368 -2.152014,-2.221647 C 11.781409,23.136647 10.701392,23.744942 9.4922931,24.0886 8.9774725,24.238111 8.0757679,24.389777 6.5811304,23.84827 4.4270703,23.124679 2.8580086,20.883331 3.0363279,18.599583 3.0037061,17.652919 3.3488675,16.723769 3.8381157,15.925061 2.5329485,15.224503 1.4686756,14.048584 1.0611184,12.606459 0.81344502,11.816973 0.82385989,10.966486 0.91519098,10.154906 1.2422711,8.2387903 2.6795811,6.5725716 4.5299585,5.9732484 5.2685364,4.290122 6.8802592,3.0349975 8.706276,2.7794663 c 1.2124148,-0.1688264 2.46744,0.084987 3.52811,0.7011837 1.545426,-1.7139736 4.237779,-2.2205077 6.293579,-1.1676231 1.568222,0.7488935 2.689625,2.3113526 2.961888,4.0151464 1.492195,0.5977882 2.749007,1.8168898 3.242225,3.3644951 0.329805,0.9581836 0.340709,2.0135956 0.127128,2.9974286 -0.381606,1.535184 -1.465322,2.842146 -2.868035,3.556463 0.0034,0.273204 0.901506,2.243045 0.751284,3.729647 -0.03281,1.858525 -1.211631,3.619894 -2.846433,4.475452 -0.953967,0.556812 -2.084452,0.546309 -3.120531,0.535398 z m -4.470079,-5.349839 c 1.322246,-0.147248 2.189053,-1.300106 2.862307,-2.338363 0.318287,-0.472954 0.561404,-1.002348 0.803,-1.505815 0.313265,0.287151 0.578698,0.828085 1.074141,0.956909 0.521892,0.162542 1.133743,0.03052 1.45325,-0.443554 0.611414,-1.140449 0.31004,-2.516537 -0.04602,-3.698347 C 18.232844,11.92927 17.945151,11.232927 17.397785,10.751793 17.514522,9.9283111 17.026575,9.0919791 16.332883,8.6609491 15.741721,9.1323278 14.842258,9.1294949 14.271975,8.6252369 13.178927,9.7400102 12.177239,9.7029996 11.209704,8.8195135 10.992255,8.6209543 10.577326,10.031484 9.1211947,9.2324497 8.2846288,9.9333947 7.6359672,10.607693 7.0611981,11.578553 6.5026891,12.62523 5.9177873,13.554793 5.867393,14.69141 c -0.024234,0.66432 0.4948601,1.360337 1.1982269,1.306329 0.702996,0.06277 1.1815208,-0.629091 1.7138087,-0.916491 0.079382,0.927141 0.1688108,1.923227 0.4821259,2.828358 0.3596254,1.171275 1.6262605,1.915695 2.8251855,1.745211 0.08481,-0.0066 0.218672,-0.01769 0.218672,-0.0176 z m 0.686342,-3.497495 c -0.643126,-0.394168 -0.33365,-1.249599 -0.359402,-1.870938 0.064,-0.749774 0.115321,-1.538054 0.452402,-2.221125 0.356724,-0.487008 1.226721,-0.299139 1.265134,0.325689 -0.02558,0.628509 -0.314101,1.25416 -0.279646,1.9057 -0.07482,0.544043 0.05418,1.155133 -0.186476,1.652391 -0.197455,0.275121 -0.599638,0.355105 -0.892012,0.208283 z m -2.808766,-0.358124 c -0.605767,-0.328664 -0.4133176,-1.155655 -0.5083256,-1.73063 0.078762,-0.66567 0.013203,-1.510085 0.5705316,-1.976886 0.545037,-0.380109 1.286917,0.270803 1.029164,0.868384 -0.274913,0.755214 -0.09475,1.580345 -0.08893,2.34609 -0.104009,0.451702 -0.587146,0.691508 -1.002445,0.493042 z"/> 64 </g> 65</svg>`; 66 67// --------------------------------------------------------------------------- 68// HTML shell 69// --------------------------------------------------------------------------- 70function page(title: string, body: string): string { 71 return `<!DOCTYPE html> 72<html lang="en"> 73<head> 74 <meta charset="UTF-8"> 75 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 76 <title>${escapeHtml(title)} &middot; jobs at tangled</title> 77 <link rel="icon" type="image/svg+xml" href="/favicon.svg"> 78 <meta property="og:image" content="https://assets.tangled.network/jobs-og.png"> 79 <meta name="twitter:card" content="summary_large_image"> 80 <meta name="twitter:image" content="https://assets.tangled.network/jobs-og.png"> 81 <link rel="preconnect" href="https://rsms.me/"> 82 <link rel="stylesheet" href="https://rsms.me/inter/inter.css"> 83 <script src="https://cdn.tailwindcss.com?plugins=typography"></script> 84 <script> 85 tailwind.config = { 86 darkMode: 'media', 87 theme: { 88 extend: { 89 fontFamily: { 90 sans: ['"InterVariable"', '"Inter"', 'system-ui', 'sans-serif'], 91 mono: ['"IBM Plex Mono"', 'ui-monospace', 'monospace'], 92 } 93 } 94 } 95 } 96 </script> 97 <style> 98 html { font-size: 14px; } 99 ::selection { background-color: rgba(250,204,21,0.3); } 100 @media (prefers-color-scheme: dark) { 101 ::selection { background-color: rgba(202,138,4,0.5); color:#fff; } 102 } 103 a { color: inherit; text-decoration: none; } 104 a:hover { text-decoration: underline; } 105 label { display:block; font-size:0.875rem; padding:0.5rem 0; color:#111827; } 106 @media (prefers-color-scheme: dark) { label { color:#f3f4f6; } } 107 .btn-create { 108 position:relative; z-index:10; display:inline-flex; min-height:30px; 109 cursor:pointer; align-items:center; justify-content:center; 110 background:transparent; padding:0.25rem 0.75rem; font-size:0.875rem; 111 color:#fff; border:none; font-family:inherit; text-decoration:none; 112 } 113 .btn-create::before { 114 content:''; position:absolute; inset:0; z-index:-10; display:block; 115 border-radius:0.25rem; border:1px solid #15803d; background:#16a34a; 116 box-shadow:inset 0 -2px 0 0 rgba(0,0,0,.1),0 1px 0 0 rgba(0,0,0,.04); 117 transition:all .15s ease-in-out; 118 } 119 .btn-create:hover { text-decoration: none; } 120 .btn-create:hover::before { background:#15803d; border-color:#166534; } 121 .btn-create:active::before { box-shadow:inset 0 2px 2px 0 rgba(0,0,0,.1); } 122 .btn-create:disabled { cursor:not-allowed; opacity:.5; } 123 @media (prefers-color-scheme: dark) { 124 .btn-create::before { background:#15803d; border-color:#166534; } 125 .btn-create:hover::before { background:#166534; } 126 } 127 </style> 128</head> 129<body class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white min-h-screen flex flex-col"> 130 131 <header class="max-w-screen-xl mx-auto w-full"> 132 <nav class="mx-auto space-x-4 px-6 py-2"> 133 <div class="flex justify-between p-0 items-center"> 134 <div> 135 <a href="/" class="text-2xl no-underline hover:no-underline flex items-center gap-2"> 136 ${DOLLY_SVG.replace('class="size-7"', 'class="size-8 text-black dark:text-white"')} 137 <span class="font-bold text-xl not-italic">tangled</span> 138 </a> 139 </div> 140 </div> 141 </div> 142 </nav> 143 </header> 144 145 ${body} 146 147 <footer class="mt-12 w-full px-6 py-4"> 148 <div class="max-w-[90ch] mx-auto flex flex-wrap justify-center items-center gap-x-4 gap-y-2 text-sm text-gray-500 dark:text-gray-400"> 149 <div class="flex items-center justify-center gap-x-2 order-last sm:order-first w-full sm:w-auto"> 150 <a href="https://tangled.org" class="no-underline hover:no-underline flex items-center">${DOLLY_SVG.replace('class="size-7"', 'class="size-5 text-gray-500 dark:text-gray-400"')}</a> 151 <span>&copy; 2026 Tangled Labs Oy.</span> 152 </div> 153 <a href="https://docs.tangled.org" class="hover:text-gray-900 dark:hover:text-gray-200 hover:underline no-underline">docs</a> 154 <a href="https://tangled.org/@tangled.org/core" class="hover:text-gray-900 dark:hover:text-gray-200 hover:underline no-underline">source</a> 155 <a href="https://chat.tangled.org" target="_blank" rel="noopener noreferrer" class="hover:text-gray-900 dark:hover:text-gray-200 hover:underline no-underline">discord</a> 156 <a href="https://bsky.app/profile/tangled.org" target="_blank" rel="noopener noreferrer" class="hover:text-gray-900 dark:hover:text-gray-200 hover:underline no-underline">bluesky</a> 157 <a href="https://x.com/tangled_org" target="_blank" rel="noopener noreferrer" class="hover:text-gray-900 dark:hover:text-gray-200 hover:underline no-underline">twitter (x)</a> 158 </div> 159 </footer> 160 161</body> 162</html>`; 163} 164 165// --------------------------------------------------------------------------- 166// Pages 167// --------------------------------------------------------------------------- 168function listingsPage(): string { 169 const rows = POSTINGS.map( 170 (p) => ` 171 <a href="/${p.slug}" class="no-underline hover:no-underline group flex items-center justify-between gap-4 px-6 py-4 hover:bg-gray-50 hover:dark:bg-gray-800/50 transition-colors"> 172 <div> 173 <div class="font-medium text-gray-900 dark:text-white group-hover:underline">${escapeHtml(p.title)}</div> 174 <div class="text-sm text-gray-500 dark:text-gray-400 mt-0.5">${escapeHtml(p.location)} · ${escapeHtml(p.type)}${p.salary ? ` · ${escapeHtml(p.salary)}` : ''}</div> 175 </div> 176 <span class="text-gray-400 dark:text-gray-500 shrink-0">→</span> 177 </a>`, 178 ).join(''); 179 180 const body = ` 181 <main class="max-w-[90ch] mx-auto w-full px-4 py-10 flex-1"> 182 <header class="mb-10"> 183 <h1 class="text-3xl font-bold dark:text-white mb-2">Open positions</h1> 184 <p class="text-gray-500 dark:text-gray-400 max-w-prose"> 185 We're a lean, globally distributed team working on building the next-generation of social coding. 186 We work remotely and try to meet once a year in-person. 187 </p> 188 </header> 189 190 ${ 191 POSTINGS.length === 0 192 ? `<p class="p-10 italic text-gray-500 dark:text-gray-400">No open positions right now; check back soon.</p>` 193 : `<div class="rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700">${rows}</div>` 194 } 195 </main>`; 196 197 return page('open positions', body); 198} 199 200function jobPage(posting: Posting): string { 201 const bodyHtml = marked.parse(posting.body) as string; 202 203 const body = ` 204 <main class="max-w-[90ch] mx-auto w-full px-4 py-10 flex-1"> 205 <div class="mb-2"> 206 <a href="/" class="text-sm text-gray-500 dark:text-gray-400 hover:underline no-underline">← All positions</a> 207 </div> 208 209 <header class="mb-8 not-prose"> 210 <p class="text-sm text-gray-500 dark:text-gray-400 mb-1">${escapeHtml(posting.location)} · ${escapeHtml(posting.type)}${posting.salary ? ` · ${escapeHtml(posting.salary)}` : ''}</p> 211 <h1 class="text-2xl font-bold dark:text-white">${escapeHtml(posting.title)}</h1> 212 </header> 213 214 <div class="prose dark:prose-invert prose-headings:no-underline text-[15px] w-full max-w-none mb-10"> 215 ${bodyHtml} 216 </div> 217 218 <div class="border-t border-gray-200 dark:border-gray-700 pt-8"> 219 <h2 class="font-bold text-lg dark:text-white mb-1">Apply for this role</h2> 220 <p class="text-base text-gray-500 dark:text-gray-400 mb-6">We read every application; if there's a fit, we'll be in touch via email.</p> 221 ${applyForm(posting.slug)} 222 </div> 223 </main>`; 224 225 return page(posting.title, body); 226} 227 228function applyForm(slug: string, error?: string): string { 229 const errorHtml = error 230 ? `<div class="mb-6 rounded border border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-900/20 px-4 py-3 text-sm text-red-600 dark:text-red-400">${escapeHtml(error)}</div>` 231 : ''; 232 233 const inputClass = 'block w-full rounded p-3 bg-gray-50 dark:bg-gray-800 dark:text-white border border-gray-300 dark:border-gray-600 focus:outline-none focus:ring-1 focus:ring-gray-400 dark:focus:ring-gray-500'; 234 const selectClass = `${inputClass} appearance-none`; 235 236 return ` 237 ${errorHtml} 238 <form method="POST" action="/${slug}/apply" id="apply-form" class="space-y-4" enctype="multipart/form-data"> 239 <div class="grid grid-cols-1 sm:grid-cols-2 gap-4"> 240 <div> 241 <label for="first_name">First name <span class="text-red-400">*</span></label> 242 <input type="text" id="first_name" name="first_name" required placeholder="Jane" autocomplete="given-name" class="${inputClass}"> 243 </div> 244 <div> 245 <label for="last_name">Last name <span class="text-red-400">*</span></label> 246 <input type="text" id="last_name" name="last_name" required placeholder="Smith" autocomplete="family-name" class="${inputClass}"> 247 </div> 248 </div> 249 250 <div class="grid grid-cols-1 sm:grid-cols-2 gap-4"> 251 <div> 252 <label for="country">Country <span class="text-red-400">*</span></label> 253 <select id="country" name="country" required class="${selectClass}"> 254 <option value="">Select a country…</option> 255 ${COUNTRIES.map((c) => `<option value="${escapeHtml(c.name)}">${escapeHtml(c.name)} ${getEmojiFlag(c.code as Parameters<typeof getEmojiFlag>[0])}</option>`).join('')} 256 </select> 257 </div> 258 <div> 259 <label for="city">City <span class="text-red-400">*</span></label> 260 <input type="text" id="city" name="city" required placeholder="Your city" autocomplete="address-level2" class="${inputClass}"> 261 </div> 262 </div> 263 264 <div> 265 <label for="email">Email <span class="text-red-400">*</span></label> 266 <input type="email" id="email" name="email" required placeholder="you@example.com" autocomplete="email" class="${inputClass}"> 267 </div> 268 269 <div class="grid grid-cols-1 sm:grid-cols-2 gap-4"> 270 <div> 271 <label for="portfolio">Portfolio <span class="text-gray-400 font-normal">(optional)</span></label> 272 <input type="url" id="portfolio" name="portfolio" placeholder="https://…" class="${inputClass}"> 273 </div> 274 <div> 275 <label for="linkedin">LinkedIn <span class="text-gray-400 font-normal">(optional)</span></label> 276 <input type="url" id="linkedin" name="linkedin" placeholder="https://linkedin.com/in/…" class="${inputClass}"> 277 </div> 278 </div> 279 280 <div> 281 <label for="resume">Résumé / CV <span class="text-red-400">*</span> <span class="text-gray-400 font-normal">(PDF)</span></label> 282 <input type="file" id="resume" name="resume" accept=".pdf,application/pdf" required class="${inputClass}"> 283 </div> 284 285 <div> 286 <label for="cover">Cover letter <span class="text-red-400">*</span></label> 287 <textarea id="cover" name="cover" required rows="10" placeholder="Tell us about yourself and why you want to work at Tangled." class="${inputClass}"></textarea> 288 </div> 289 290 <div class="flex items-center gap-4 pt-2"> 291 <button type="submit" class="btn-create" id="submit-btn">Submit application →</button> 292 </div> 293 <p class="text-sm text-gray-400 dark:text-gray-500 mt-6">By submitting this form you agree to your data being processed by our subprocessors: Cloudflare and Linear.</p> 294 </form> 295 <script> 296 document.getElementById('apply-form').addEventListener('submit', function() { 297 var btn = document.getElementById('submit-btn'); 298 btn.disabled = true; 299 btn.textContent = 'Submitting…'; 300 }); 301 </script>`; 302} 303 304function successPage(firstName: string, posting: Posting): string { 305 const body = ` 306 <main class="max-w-[90ch] mx-auto w-full px-4 py-16 text-center"> 307 <div class="mb-6 text-5xl">🎉</div> 308 <h1 class="text-2xl font-bold dark:text-white mb-3">Application received</h1> 309 <p class="text-gray-500 dark:text-gray-400 max-w-md mx-auto mb-8"> 310 Thanks, ${escapeHtml(firstName)}! We've received your application for <strong class="text-gray-700 dark:text-gray-300">${escapeHtml(posting.title)}</strong> and will be in touch soon. 311 </p> 312 <a href="/" class="btn-create">← View all positions</a> 313 </main>`; 314 315 return page('application received', body); 316} 317 318// --------------------------------------------------------------------------- 319// Linear 320// --------------------------------------------------------------------------- 321 322// Upload a file to Linear's asset storage and return the public asset URL. 323async function uploadToLinear(env: Env, file: File): Promise<string> { 324 const query = ` 325 mutation FileUpload($contentType: String!, $filename: String!, $size: Int!) { 326 fileUpload(contentType: $contentType, filename: $filename, size: $size) { 327 uploadFile { 328 uploadUrl 329 assetUrl 330 headers { key value } 331 } 332 } 333 }`; 334 335 const metaResp = await fetch('https://api.linear.app/graphql', { 336 method: 'POST', 337 headers: { Authorization: env.LINEAR_API_KEY, 'Content-Type': 'application/json' }, 338 body: JSON.stringify({ 339 query, 340 variables: { contentType: file.type, filename: file.name, size: file.size }, 341 }), 342 }); 343 344 const meta = (await metaResp.json()) as { 345 data?: { fileUpload?: { uploadFile?: { uploadUrl: string; assetUrl: string; headers: { key: string; value: string }[] } } }; 346 errors?: unknown[]; 347 }; 348 349 const uploadFile = meta.data?.fileUpload?.uploadFile; 350 if (!uploadFile || meta.errors) { 351 throw new Error(`Linear fileUpload error: ${JSON.stringify(meta.errors ?? meta)}`); 352 } 353 354 const uploadHeaders: Record<string, string> = { 'Content-Type': file.type }; 355 for (const { key, value } of uploadFile.headers) uploadHeaders[key] = value; 356 357 const uploadResp = await fetch(uploadFile.uploadUrl, { 358 method: 'PUT', 359 headers: uploadHeaders, 360 body: await file.arrayBuffer(), 361 }); 362 363 if (!uploadResp.ok) { 364 throw new Error(`Resume upload failed: ${uploadResp.status} ${uploadResp.statusText}`); 365 } 366 367 return uploadFile.assetUrl; 368} 369 370async function getOrCreateLabel(env: Env, name: string): Promise<string | null> { 371 try { 372 const searchQuery = ` 373 query Labels($teamId: ID!) { 374 issueLabels(filter: { team: { id: { eq: $teamId } } }) { 375 nodes { id name } 376 } 377 }`; 378 379 const searchResp = await fetch('https://api.linear.app/graphql', { 380 method: 'POST', 381 headers: { Authorization: env.LINEAR_API_KEY, 'Content-Type': 'application/json' }, 382 body: JSON.stringify({ query: searchQuery, variables: { teamId: env.LINEAR_TEAM_ID } }), 383 }); 384 385 const searchResult = (await searchResp.json()) as { 386 data?: { issueLabels?: { nodes: { id: string; name: string }[] } }; 387 }; 388 389 const existing = searchResult.data?.issueLabels?.nodes.find((l) => l.name === name); 390 if (existing) return existing.id; 391 392 const createQuery = ` 393 mutation LabelCreate($input: IssueLabelCreateInput!) { 394 issueLabelCreate(input: $input) { 395 success 396 issueLabel { id } 397 } 398 }`; 399 400 const createResp = await fetch('https://api.linear.app/graphql', { 401 method: 'POST', 402 headers: { Authorization: env.LINEAR_API_KEY, 'Content-Type': 'application/json' }, 403 body: JSON.stringify({ query: createQuery, variables: { input: { name, teamId: env.LINEAR_TEAM_ID } } }), 404 }); 405 406 const created = (await createResp.json()) as { 407 data?: { issueLabelCreate?: { success: boolean; issueLabel: { id: string } } }; 408 }; 409 410 return created.data?.issueLabelCreate?.issueLabel?.id ?? null; 411 } catch (err) { 412 console.error('Failed to get/create label:', err); 413 return null; 414 } 415} 416 417async function createLinearIssue( 418 env: Env, 419 posting: Posting, 420 data: { 421 firstName: string; 422 lastName: string; 423 email: string; 424 country: string; 425 city: string; 426 portfolio: string; 427 linkedin: string; 428 cover: string; 429 resume: File | null; 430 }, 431): Promise<void> { 432 const fullName = `${data.firstName} ${data.lastName}`; 433 const title = fullName; 434 435 const [resumeUrl, labelId] = await Promise.all([ 436 data.resume ? uploadToLinear(env, data.resume) : Promise.resolve(null), 437 getOrCreateLabel(env, posting.title), 438 ]); 439 440 const description = [ 441 `**Name:** ${fullName}`, 442 `**Email:** ${data.email}`, 443 `**Location:** ${data.city}, ${data.country}`, 444 `**Role:** ${posting.title}`, 445 data.portfolio ? `**Portfolio:** ${data.portfolio}` : null, 446 data.linkedin ? `**LinkedIn:** ${data.linkedin}` : null, 447 resumeUrl ? `**Résumé:** [${data.resume!.name}](${resumeUrl})` : null, 448 '', 449 '---', 450 '', 451 '## Cover letter', 452 '', 453 data.cover, 454 ] 455 .filter((l): l is string => l !== null) 456 .join('\n'); 457 458 const query = ` 459 mutation CreateIssue($input: IssueCreateInput!) { 460 issueCreate(input: $input) { 461 success 462 issue { id identifier } 463 } 464 }`; 465 466 const input: Record<string, unknown> = { title, description, teamId: env.LINEAR_TEAM_ID }; 467 if (labelId) input.labelIds = [labelId]; 468 469 const resp = await fetch('https://api.linear.app/graphql', { 470 method: 'POST', 471 headers: { Authorization: env.LINEAR_API_KEY, 'Content-Type': 'application/json' }, 472 body: JSON.stringify({ query, variables: { input } }), 473 }); 474 475 const result = (await resp.json()) as { data?: { issueCreate?: { success: boolean } }; errors?: unknown[] }; 476 477 if (result.errors || !result.data?.issueCreate?.success) { 478 throw new Error(`Linear error: ${JSON.stringify(result.errors ?? result)}`); 479 } 480} 481 482// --------------------------------------------------------------------------- 483// Router 484// --------------------------------------------------------------------------- 485export default { 486 async fetch(request: Request, env: Env): Promise<Response> { 487 const url = new URL(request.url); 488 const parts = url.pathname.replace(/^\//, '').split('/'); 489 490 // GET /favicon.svg 491 if (request.method === 'GET' && url.pathname === '/favicon.svg') { 492 return new Response(DOLLY_SVG, { headers: { 'Content-Type': 'image/svg+xml' } }); 493 } 494 495 // GET / — listings 496 if (request.method === 'GET' && url.pathname === '/') { 497 return html(listingsPage()); 498 } 499 500 // GET /:slug — job posting 501 if (request.method === 'GET' && parts.length === 1 && parts[0]) { 502 const posting = POSTINGS_BY_SLUG.get(parts[0]); 503 if (!posting) return notFound(); 504 return html(jobPage(posting)); 505 } 506 507 // POST /:slug/apply — submit application 508 if (request.method === 'POST' && parts.length === 2 && parts[1] === 'apply') { 509 const posting = POSTINGS_BY_SLUG.get(parts[0]); 510 if (!posting) return notFound(); 511 512 let formData: FormData; 513 try { 514 formData = await request.formData(); 515 } catch { 516 return html(jobPage(posting), 400); 517 } 518 519 const firstName = (formData.get('first_name') as string | null)?.trim() ?? ''; 520 const lastName = (formData.get('last_name') as string | null)?.trim() ?? ''; 521 const email = (formData.get('email') as string | null)?.trim() ?? ''; 522 const country = (formData.get('country') as string | null)?.trim() ?? ''; 523 const city = (formData.get('city') as string | null)?.trim() ?? ''; 524 const portfolio = (formData.get('portfolio') as string | null)?.trim() ?? ''; 525 const linkedin = (formData.get('linkedin') as string | null)?.trim() ?? ''; 526 const cover = (formData.get('cover') as string | null)?.trim() ?? ''; 527 const resumeEntry = formData.get('resume'); 528 const resume = 529 resumeEntry instanceof File && resumeEntry.size > 0 && resumeEntry.type === 'application/pdf' 530 ? resumeEntry 531 : null; 532 533 if (!firstName || !lastName || !email || !country || !city || !cover || !resume) { 534 const bodyHtml = marked.parse(posting.body) as string; 535 const body = ` 536 <main class="max-w-[90ch] mx-auto w-full px-4 py-10 flex-1"> 537 <div class="mb-2"> 538 <a href="/" class="text-sm text-gray-500 dark:text-gray-400 hover:underline no-underline">← All positions</a> 539 </div> 540 <header class="mb-8 not-prose"> 541 <p class="text-sm text-gray-500 dark:text-gray-400 mb-1">${escapeHtml(posting.location)} · ${escapeHtml(posting.type)}${posting.salary ? ` · ${escapeHtml(posting.salary)}` : ''}</p> 542 <h1 class="text-2xl font-bold dark:text-white">${escapeHtml(posting.title)}</h1> 543 </header> 544 <div class="prose dark:prose-invert prose-headings:no-underline text-[15px] w-full max-w-none mb-10">${bodyHtml}</div> 545 <div class="border-t border-gray-200 dark:border-gray-700 pt-8"> 546 <h2 class="font-bold text-lg dark:text-white mb-1">Apply for this role</h2> 547 <p class="text-base text-gray-500 dark:text-gray-400 mb-6">We read every application; if there's a fit, we'll be in touch via email.</p> 548 ${applyForm(posting.slug, 'Please fill in all required fields.')} 549 </div> 550 </main>`; 551 return html(page(posting.title, body), 400); 552 } 553 554 try { 555 await createLinearIssue(env, posting, { firstName, lastName, email, country, city, portfolio, linkedin, cover, resume }); 556 return html(successPage(firstName, posting)); 557 } catch (err) { 558 console.error('Linear issue creation failed:', err); 559 const bodyHtml = marked.parse(posting.body) as string; 560 const body = ` 561 <main class="max-w-[90ch] mx-auto w-full px-4 py-10 flex-1"> 562 <div class="mb-2"> 563 <a href="/" class="text-sm text-gray-500 dark:text-gray-400 hover:underline no-underline">← All positions</a> 564 </div> 565 <header class="mb-8 not-prose"> 566 <p class="text-sm text-gray-500 dark:text-gray-400 mb-1">${escapeHtml(posting.location)} · ${escapeHtml(posting.type)}${posting.salary ? ` · ${escapeHtml(posting.salary)}` : ''}</p> 567 <h1 class="text-2xl font-bold dark:text-white">${escapeHtml(posting.title)}</h1> 568 </header> 569 <div class="prose dark:prose-invert prose-headings:no-underline text-[15px] w-full max-w-none mb-10">${bodyHtml}</div> 570 <div class="border-t border-gray-200 dark:border-gray-700 pt-8"> 571 <h2 class="font-bold text-lg dark:text-white mb-1">Apply for this role</h2> 572 <p class="text-base text-gray-500 dark:text-gray-400 mb-6">We read every application; if there's a fit, we'll be in touch via email.</p> 573 ${applyForm(posting.slug, 'Something went wrong submitting your application. Please try again.')} 574 </div> 575 </main>`; 576 return html(page(posting.title, body), 500); 577 } 578 } 579 580 return notFound(); 581 }, 582} satisfies ExportedHandler<Env>; 583 584// --------------------------------------------------------------------------- 585// Helpers 586// --------------------------------------------------------------------------- 587function html(body: string, status = 200): Response { 588 return new Response(body, { status, headers: { 'Content-Type': 'text/html; charset=utf-8' } }); 589} 590 591function notFound(): Response { 592 return new Response('not found', { status: 404 }); 593} 594 595function escapeHtml(str: string): string { 596 return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;'); 597}