Monorepo for Tangled
tangled.org
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)} · 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>© 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
597}