website

#astro#js#html#css

git clone https://git.pyrossh.dev/website

木 Personal website of pyrossh. Built with astrojs, shiki, vite.


55e32cf — pyrossh 2026-07-07T13:58:14+05:30
fix: remove spamscanner dependency (incompatible with Workers), fix pnpm config, resolve dev build errors

Generated files not shown: pnpm-lock.yaml

docs/superpowers/plans/2026-07-07-rewrite-typed-htmx-workers.md ADDED
@@ -0,0 +1,1406 @@
1
+ # pyrossh.dev Rewrite — typed-htmx Workers Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task.
4
+
5
+ **Goal:** Rewrite pyrossh.dev from Astro into a pnpm monorepo of Cloudflare Workers using typed-htmx/typed-html JSX.
6
+
7
+ **Architecture:** ~18 Workers, one per URL pattern, routed via Cloudflare Routes. Shared code in `@pyrossh/{config,types,core,ui}` packages. Each Worker generates HTML server-side via typed-htmx JSX. htmx drives interactivity (issue CRUD, file tree, tab navigation). Dev mode uses a single router Worker that imports all handlers.
8
+
9
+ **Tech Stack:** Cloudflare Workers, typed-htmx/typed-html, htmx, pnpm workspaces, typescript, wrangler
10
+
11
+ ## Global Constraints
12
+
13
+ - `"jsx": "react-jsx"` and `"jsxImportSource": "typed-htmx/typed-html"` in all tsconfigs
14
+ - All HTML generated server-side via typed-htmx JSX
15
+ - htmx for all interactivity (no custom JS except timeago element)
16
+ - Each worker in its own directory under `packages/workers/`
17
+ - Each worker has its own `wrangler.jsonc`, `package.json`, `tsconfig.json`
18
+ - Shared CSS as string export in `@pyrossh/ui/css.ts`
19
+ - R2 bucket binding named `REPOS` in every worker that needs it
20
+ - Blog content stays as markdown files read from R2 or filesystem
21
+ - No Astro dependencies in final project
22
+
23
+ ---
24
+
25
+ ### Task 1: Monorepo Scaffold & Base Config
26
+
27
+ **Files:**
28
+ - Create: `packages/shared/config/package.json`
29
+ - Create: `packages/shared/config/tsconfig.json`
30
+ - Create: `packages/shared/config/src/index.ts`
31
+ - Modify: `package.json` (root)
32
+ - Modify: `pnpm-workspace.yaml`
33
+ - Create: `tsconfig.base.json`
34
+
35
+ **Interfaces:**
36
+ - Produces: `@pyrossh/config` package exporting `SITE_TITLE`, `SITE_DESCRIPTION`, `REPOS`, `TOOLS`, site config
37
+
38
+ - [ ] **Step 1: Update root `pnpm-workspace.yaml`**
39
+
40
+ ```yaml
41
+ packages:
42
+ - 'packages/shared/*'
43
+ - 'packages/workers/**'
44
+ ```
45
+
46
+ - [ ] **Step 2: Create `tsconfig.base.json`**
47
+
48
+ ```json
49
+ {
50
+ "compilerOptions": {
51
+ "target": "ESNext",
52
+ "module": "ESNext",
53
+ "moduleResolution": "bundler",
54
+ "jsx": "react-jsx",
55
+ "jsxImportSource": "typed-htmx/typed-html",
56
+ "strict": true,
57
+ "skipLibCheck": true,
58
+ "forceConsistentCasingInFileNames": true,
59
+ "resolveJsonModule": true,
60
+ "isolatedModules": true,
61
+ "noEmit": true,
62
+ "types": ["@cloudflare/workers-types"]
63
+ }
64
+ }
65
+ ```
66
+
67
+ - [ ] **Step 3: Create `packages/shared/config/package.json`**
68
+
69
+ ```json
70
+ {
71
+ "name": "@pyrossh/config",
72
+ "version": "0.0.0",
73
+ "private": true,
74
+ "type": "module",
75
+ "main": "src/index.ts",
76
+ "types": "src/index.ts"
77
+ }
78
+ ```
79
+
80
+ - [ ] **Step 4: Create `packages/shared/config/tsconfig.json`**
81
+
82
+ ```json
83
+ {
84
+ "extends": "../../../tsconfig.base.json",
85
+ "include": ["src"]
86
+ }
87
+ ```
88
+
89
+ - [ ] **Step 5: Create `packages/shared/config/src/index.ts`**
90
+
91
+ Port the contents of `src/consts.ts` — but since Workers can't import PNGs directly, change tool images to URL strings (reference assets served by the assets Worker later). For now, use relative paths.
92
+
93
+ ```typescript
94
+ export const SITE_TITLE = "pyrossh";
95
+ export const SITE_DESCRIPTION = "Welcome to my website!";
96
+ export const REPOS = [
97
+ {
98
+ title: "rust-embed",
99
+ description: "rust macro which loads files into the rust binary at compile time during release and loads the file from the fs during dev.",
100
+ tags: ["rust", "proc-macro", "http"],
101
+ },
102
+ // ... all 14 repos from original src/consts.ts
103
+ ];
104
+ export const TOOLS = [
105
+ { name: "Stats", link: "https://github.com/exelban/stats", image: "/assets/logos/stats.png" },
106
+ // ... all 8 tools from original src/consts.ts
107
+ ];
108
+ export const NAV_ITEMS = [
109
+ { href: "/cv", label: "cv" },
110
+ { href: "/posts", label: "posts" },
111
+ ] as const;
112
+ export const SITE_URL = "https://pyrossh.dev";
113
+ ```
114
+
115
+ - [ ] **Step 6: Install root dependency**
116
+
117
+ ```bash
118
+ pnpm add -w typed-htmx
119
+ pnpm add -w -D @cloudflare/workers-types
120
+ ```
121
+
122
+ - [ ] **Step 7: Commit**
123
+
124
+ ```bash
125
+ git add pnpm-workspace.yaml tsconfig.base.json packages/shared/config/ package.json
126
+ git commit -m "feat: scaffold monorepo with shared config package"
127
+ ```
128
+
129
+ ---
130
+
131
+ ### Task 2: @pyrossh/types — Shared TypeScript Types
132
+
133
+ **Files:**
134
+ - Create: `packages/shared/types/package.json`
135
+ - Create: `packages/shared/types/tsconfig.json`
136
+ - Create: `packages/shared/types/src/index.ts`
137
+ - Create: `packages/shared/types/src/env.d.ts`
138
+
139
+ **Interfaces:**
140
+ - Consumes: nothing
141
+ - Produces: `Env`, `R2Bucket`-aware interfaces consumed by @pyrossh/core and every worker
142
+
143
+ - [ ] **Step 1: Create `packages/shared/types/package.json`**
144
+
145
+ ```json
146
+ {
147
+ "name": "@pyrossh/types",
148
+ "version": "0.0.0",
149
+ "private": true,
150
+ "type": "module",
151
+ "main": "src/index.ts",
152
+ "types": "src/index.ts"
153
+ }
154
+ ```
155
+
156
+ - [ ] **Step 2: Create `packages/shared/types/tsconfig.json`**
157
+
158
+ ```json
159
+ {
160
+ "extends": "../../../tsconfig.base.json",
161
+ "include": ["src"]
162
+ }
163
+ ```
164
+
165
+ - [ ] **Step 3: Create `packages/shared/types/src/env.d.ts`**
166
+
167
+ ```typescript
168
+ export interface Env {
169
+ REPOS: R2Bucket;
170
+ }
171
+ ```
172
+
173
+ - [ ] **Step 4: Create `packages/shared/types/src/index.ts`**
174
+
175
+ ```typescript
176
+ export type { Env } from './env.d.ts';
177
+
178
+ export interface Commit {
179
+ hash: string;
180
+ author: string;
181
+ date: string;
182
+ message: string;
183
+ body?: string;
184
+ }
185
+
186
+ export interface FileEntry {
187
+ name: string;
188
+ size: number;
189
+ ext: string;
190
+ absolutePath: string;
191
+ }
192
+
193
+ export interface FileNode {
194
+ name: string;
195
+ path: string;
196
+ size: number;
197
+ ext: string;
198
+ absolutePath: string;
199
+ isDirectory: boolean;
200
+ children?: FileNode[];
201
+ }
202
+
203
+ export interface RuntimeRepo {
204
+ id: string;
205
+ data: {
206
+ title: string;
207
+ description: string;
208
+ tags: string[];
209
+ };
210
+ }
211
+
212
+ export interface RuntimePost {
213
+ id: string;
214
+ data: {
215
+ title: string;
216
+ description: string;
217
+ pubDate: Date;
218
+ updatedDate?: Date;
219
+ heroImage?: string;
220
+ };
221
+ body: string;
222
+ html: string;
223
+ }
224
+
225
+ export type GitBugIssueState = "open" | "closed";
226
+
227
+ export interface GitBugIssue {
228
+ id: string;
229
+ title: string;
230
+ state: GitBugIssueState;
231
+ author?: string;
232
+ createdAt: string;
233
+ updatedAt: string;
234
+ labels: string[];
235
+ body?: string;
236
+ comments: GitBugComment[];
237
+ }
238
+
239
+ export interface GitBugComment {
240
+ id: string;
241
+ author?: string;
242
+ createdAt: string;
243
+ body: string;
244
+ }
245
+
246
+ export interface GitBugIssuesResult {
247
+ issues: GitBugIssue[];
248
+ isAvailable: boolean;
249
+ }
250
+ ```
251
+
252
+ - [ ] **Step 5: Commit**
253
+
254
+ ```bash
255
+ git add packages/shared/types/
256
+ git commit -m "feat: add shared types package"
257
+ ```
258
+
259
+ ---
260
+
261
+ ### Task 3: @pyrossh/core — Business Logic (Ported from src/utils/)
262
+
263
+ **Files:**
264
+ - Create: `packages/shared/core/package.json`
265
+ - Create: `packages/shared/core/tsconfig.json`
266
+ - Create: `packages/shared/core/src/index.ts`
267
+ - Create: `packages/shared/core/src/gitReader.ts` (port from `src/utils/gitReader.ts`)
268
+ - Create: `packages/shared/core/src/markdown.ts` (port from `src/utils/markdown.ts`)
269
+ - Create: `packages/shared/core/src/content.ts` (port from `src/utils/content.ts`)
270
+ - Create: `packages/shared/core/src/gitBug.ts` (port from `src/utils/gitBug.ts`)
271
+ - Create: `packages/shared/core/src/files.ts` (port from `src/utils/files.ts`)
272
+ - Create: `packages/shared/core/src/repoContent.ts` (port from `src/utils/repoContent.ts`)
273
+
274
+ **Interfaces:**
275
+ - Consumes: `@pyrossh/types`, `@pyrossh/config`
276
+ - Produces all git reader, markdown, content, issue CRUD, file tree, and repo content functions
277
+
278
+ - [ ] **Step 1: Create `packages/shared/core/package.json`**
279
+
280
+ ```json
281
+ {
282
+ "name": "@pyrossh/core",
283
+ "version": "0.0.0",
284
+ "private": true,
285
+ "type": "module",
286
+ "main": "src/index.ts",
287
+ "types": "src/index.ts",
288
+ "dependencies": {
289
+ "@pyrossh/config": "workspace:*",
290
+ "@pyrossh/types": "workspace:*",
291
+ "unified": "^11.0.5",
292
+ "remark-parse": "^11.0.0",
293
+ "remark-rehype": "^11.1.2",
294
+ "rehype-stringify": "^10.0.1",
295
+ "rehype-expressive-code": "^0.44.0",
296
+ "gray-matter": "^4.0.3",
297
+ "diff": "^9.0.0",
298
+ "pretty-bytes": "^7.1.0",
299
+ "diff2html": "^3.4.52",
300
+ "spamscanner": "^6.1.5"
301
+ }
302
+ }
303
+ ```
304
+
305
+ - [ ] **Step 2: Copy `gitReader.ts` verbatim** — this file is a pure R2-dependent module. No changes needed except import paths. Copy from `src/utils/gitReader.ts` to `packages/shared/core/src/gitReader.ts`.
306
+
307
+ - [ ] **Step 3: Create `packages/shared/core/src/markdown.ts`**
308
+
309
+ ```typescript
310
+ import rehypeExpressiveCode from "rehype-expressive-code";
311
+ import rehypeStringify from "rehype-stringify";
312
+ import remarkParse from "remark-parse";
313
+ import remarkRehype from "remark-rehype";
314
+ import { unified } from "unified";
315
+
316
+ // Minimal ec config for Workers context
317
+ const ecConfig = {
318
+ shiki: { engine: "javascript" },
319
+ styleOverrides: { codeFontSize: "0.8rem", uiFontSize: "0.8rem" },
320
+ };
321
+
322
+ export const renderMarkdown = async (markdown: string) => {
323
+ const file = await unified()
324
+ .use(remarkParse)
325
+ .use(remarkRehype)
326
+ .use(rehypeExpressiveCode, ecConfig)
327
+ .use(rehypeStringify)
328
+ .process(markdown);
329
+ return String(file);
330
+ };
331
+ ```
332
+
333
+ - [ ] **Step 4: Create `packages/shared/core/src/content.ts`**
334
+
335
+ Port from `src/utils/content.ts` — change filesystem reads to R2Bucket reads. Blog content will be stored in R2.
336
+
337
+ ```typescript
338
+ import matter from "gray-matter";
339
+ import { REPOS } from "@pyrossh/config";
340
+ import type { RuntimeRepo, RuntimePost } from "@pyrossh/types";
341
+ import { renderMarkdown } from "./markdown";
342
+
343
+ const toDate = (value: unknown) => value instanceof Date ? value : new Date(String(value));
344
+
345
+ export const getRepos = (): RuntimeRepo[] =>
346
+ REPOS.map((repo) => ({ id: repo.title, data: { ...repo } }));
347
+
348
+ export const getRepo = (repoId?: string) => getRepos().find((repo) => repo.id === repoId);
349
+
350
+ const CONTENT_PREFIX = "content/";
351
+
352
+ export const getPosts = async (bucket: R2Bucket): Promise<RuntimePost[]> => {
353
+ const objects = await bucket.list({ prefix: CONTENT_PREFIX });
354
+ const posts: RuntimePost[] = [];
355
+
356
+ for (const obj of objects.objects) {
357
+ if (!obj.key.endsWith(".md") && !obj.key.endsWith(".mdx")) continue;
358
+ const file = await bucket.get(obj.key);
359
+ if (!file) continue;
360
+ const source = await file.text();
361
+ const parsed = matter(source);
362
+ const id = obj.key.replace(CONTENT_PREFIX, "").replace(/\.(md|mdx)$/, "");
363
+
364
+ posts.push({
365
+ id,
366
+ data: {
367
+ title: String(parsed.data.title ?? ""),
368
+ description: String(parsed.data.description ?? ""),
369
+ pubDate: toDate(parsed.data.pubDate),
370
+ updatedDate: parsed.data.updatedDate ? toDate(parsed.data.updatedDate) : undefined,
371
+ heroImage: parsed.data.heroImage ? String(parsed.data.heroImage) : undefined,
372
+ },
373
+ body: parsed.content,
374
+ html: await renderMarkdown(parsed.content),
375
+ });
376
+ }
377
+
378
+ return posts.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
379
+ };
380
+
381
+ export const getPost = async (bucket: R2Bucket, postId?: string) => {
382
+ const posts = await getPosts(bucket);
383
+ return posts.find((post) => post.id === postId);
384
+ };
385
+ ```
386
+
387
+ - [ ] **Step 5: Copy `gitBug.ts` verbatim** from `src/utils/gitBug.ts` to `packages/shared/core/src/gitBug.ts`. The module already accepts `R2Bucket` as parameters and uses `crypto.randomUUID()` which is available in Workers runtime. No changes needed.
388
+
389
+ - [ ] **Step 6: Copy `files.ts` verbatim** from `src/utils/files.ts` to `packages/shared/core/src/files.ts`. This imports from `vscode-icons` which is installed in root. Add it as a dependency of @pyrossh/core.
390
+
391
+ - [ ] **Step 7: Create `packages/shared/core/src/repoContent.ts`**
392
+
393
+ ```typescript
394
+ export const getRepoReadme = async (
395
+ bucket: R2Bucket,
396
+ repoId: string,
397
+ ): Promise<string | undefined> => {
398
+ const obj = await bucket.get(`${repoId}/README.md`);
399
+ if (obj === null) return undefined;
400
+ return await obj.text();
401
+ };
402
+ ```
403
+
404
+ - [ ] **Step 8: Create `packages/shared/core/src/index.ts` — re-exports**
405
+
406
+ ```typescript
407
+ export { renderMarkdown } from "./markdown";
408
+ export { getRepos, getRepo, getPosts, getPost } from "./content";
409
+ export { getGitBugIssues, getGitBugIssue, createGitBugIssue, addGitBugIssueComment, setGitBugIssueState } from "./gitBug";
410
+ export type { GitBugIssueState, GitBugIssue, GitBugComment, GitBugIssuesResult } from "./gitBug";
411
+ export { getRepoReadme } from "./repoContent";
412
+ export { buildFileTree, sortChildren, sortAll, resolveFolderIcon, resolveFileIcon } from "./files";
413
+ export type { FileNode } from "./files";
414
+ export {
415
+ getCommits, getFiles, getFileHistory, getFileContentData, generateHTMLDiff,
416
+ } from "./gitReader";
417
+ export type { Commit } from "./gitReader";
418
+ ```
419
+
420
+ - [ ] **Step 9: Commit**
421
+
422
+ ```bash
423
+ git add packages/shared/core/ package.json pnpm-lock.yaml
424
+ git commit -m "feat: port business logic to @pyrossh/core package"
425
+ ```
426
+
427
+ ---
428
+
429
+ ### Task 4: @pyrossh/ui — typed-htmx JSX Components
430
+
431
+ **Files:**
432
+ - Create: `packages/shared/ui/package.json`
433
+ - Create: `packages/shared/ui/tsconfig.json`
434
+ - Create: `packages/shared/ui/src/index.ts`
435
+ - Create: `packages/shared/ui/src/css.ts` — global CSS string
436
+ - Create: `packages/shared/ui/src/layout.tsx` — BaseLayout equivalent
437
+ - Create: `packages/shared/ui/src/header.tsx` — Header component
438
+ - Create: `packages/shared/ui/src/footer.tsx` — Footer component
439
+ - Create: `packages/shared/ui/src/repo-layout.tsx` — RepoLayout equivalent
440
+ - Create: `packages/shared/ui/src/file-layout.tsx` — FileLayout equivalent
441
+ - Create: `packages/shared/ui/src/repo-nav.tsx` — repo tab navigation
442
+ - Create: `packages/shared/ui/src/commit-entry.tsx` — Commit list item
443
+ - Create: `packages/shared/ui/src/issue-card.tsx` — Issue display card
444
+ - Create: `packages/shared/ui/src/file-tree.tsx` — Recursive file tree
445
+ - Create: `packages/shared/ui/src/formatted-date.tsx` — Date formatter
446
+
447
+ **Interfaces:**
448
+ - Consumes: `typed-htmx`, `@pyrossh/config`
449
+ - Produces: JSX component functions used by every worker
450
+
451
+ - [ ] **Step 1: Create `packages/shared/ui/package.json`**
452
+
453
+ ```json
454
+ {
455
+ "name": "@pyrossh/ui",
456
+ "version": "0.0.0",
457
+ "private": true,
458
+ "type": "module",
459
+ "main": "src/index.ts",
460
+ "types": "src/index.ts",
461
+ "dependencies": {
462
+ "@pyrossh/config": "workspace:*",
463
+ "typed-htmx": "workspace:*"
464
+ }
465
+ }
466
+ ```
467
+
468
+ - [ ] **Step 2: Create `packages/shared/ui/tsconfig.json`**
469
+
470
+ ```json
471
+ {
472
+ "extends": "../../../tsconfig.base.json",
473
+ "include": ["src"]
474
+ }
475
+ ```
476
+
477
+ - [ ] **Step 3: Create `packages/shared/ui/src/css.ts`**
478
+
479
+ Port all CSS from `src/layouts/BaseLayout.astro`'s `<style is:global>` block + CSS reset. This is a single exported string.
480
+
481
+ ```typescript
482
+ export const globalCSS = `
483
+ :root {
484
+ --color-black: light-dark(hsl(0, 0%, 0%), hsl(0, 0%, 0%));
485
+ --color-white: light-dark(hsl(0, 0%, 100%), hsl(0, 0%, 100%));
486
+ --color-accent: hsl(57.25deg 100% 70%);
487
+ --color-body-bg: light-dark(hsl(0, 0%, 100%), hsl(210, 13%, 15%));
488
+ --color-box-bg: light-dark(hsl(0, 0%, 95%), hsl(210, 13%, 9%));
489
+ --color-text: light-dark(hsl(0, 0%, 13%), hsl(210, 17%, 98%));
490
+ --color-link: light-dark(hsl(211, 100%, 45%), hsl(211, 100%, 60%));
491
+ --color-post-link: light-dark(hsl(0, 0%, 0%), hsl(211, 100%, 60%));
492
+ --color-highlight-bg: light-dark(hsl(183, 80%, 88%), hsl(183, 80%, 18%));
493
+ --color-code-fg: light-dark(hsl(348, 86%, 43%), hsl(348, 86%, 80%));
494
+ --color-code-bg: light-dark(hsl(0, 0%, 95%), hsl(0, 0%, 0%));
495
+ --color-pre-bg: light-dark(hsl(0, 0%, 95%), hsl(0, 0%, 0%));
496
+ --color-tag: light-dark(hsl(152, 96%, 35%), hsl(152, 96%, 44%));
497
+ --btn-light: none;
498
+ --btn-dark: block;
499
+ }
500
+ [data-theme="dark"] {
501
+ --btn-light: block;
502
+ --btn-dark: none;
503
+ }
504
+ body {
505
+ display: flex;
506
+ flex-direction: column;
507
+ flex: 1;
508
+ background-color: var(--color-body-bg);
509
+ color: var(--color-text);
510
+ font-family: system-ui;
511
+ text-rendering: optimizeLegibility;
512
+ font-variant-ligatures: common-ligatures;
513
+ font-kerning: normal;
514
+ font-size: 1.25rem;
515
+ line-height: 1.5;
516
+ min-height: 100vh;
517
+ }
518
+ main {
519
+ margin-top: 1rem;
520
+ margin-bottom: 1rem;
521
+ padding-bottom: 1em;
522
+ }
523
+ a { color: var(--color-link); }
524
+ /* Copy remaining global styles from BaseLayout.astro style block */
525
+ `;
526
+ ```
527
+
528
+ (Include the full CSS from BaseLayout, RepoLayout, FileLayout, and all component styles as one global stylesheet — port all `<style>` blocks from all Astro components.)
529
+
530
+ - [ ] **Step 4: Create `packages/shared/ui/src/layout.tsx`**
531
+
532
+ ```tsx
533
+ import { globalCSS } from "./css";
534
+ import { Header } from "./header";
535
+ import { Footer } from "./footer";
536
+ import { SITE_TITLE, SITE_DESCRIPTION, SITE_URL } from "@pyrossh/config";
537
+
538
+ interface LayoutProps {
539
+ title: string;
540
+ description?: string;
541
+ request?: Request;
542
+ children: any;
543
+ }
544
+
545
+ export function Layout({ title, description, request, children }: LayoutProps) {
546
+ const url = request ? new URL(request.url) : undefined;
547
+ const canonicalURL = url ? `${SITE_URL}${url.pathname}` : SITE_URL;
548
+ return (
549
+ <html lang="en">
550
+ <head>
551
+ <meta charset="utf-8" />
552
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
553
+ <meta name="theme-color" content="#131618" />
554
+ <link rel="icon" type="image/svg+xml" href="/assets/icons/icon.svg" />
555
+ <link rel="alternate" type="application/rss+xml" title={title} href="/rss.xml" />
556
+ <link rel="canonical" href={canonicalURL} />
557
+ <title>{title}</title>
558
+ <meta name="title" content={title} />
559
+ <meta name="description" content={description || SITE_DESCRIPTION} />
560
+ <meta name="author" content="pyrossh" />
561
+ <meta name="keywords" content="pyrossh.dev,pyrossh,website" />
562
+ <meta property="og:site_name" content="pyrossh.dev" />
563
+ <meta property="og:type" content="website" />
564
+ <meta property="og:url" content={url?.href || SITE_URL} />
565
+ <meta property="og:title" content={title} />
566
+ <meta property="og:description" content={description || SITE_DESCRIPTION} />
567
+ <meta property="og:image" content="/assets/icons/icon.svg" />
568
+ <meta property="twitter:card" content="summary_large_image" />
569
+ <meta property="twitter:url" content={url?.href || SITE_URL} />
570
+ <meta property="twitter:title" content={title} />
571
+ <meta property="twitter:description" content={description || SITE_DESCRIPTION} />
572
+ <meta property="twitter:image" content="/assets/icons/icon.svg" />
573
+ <style>{globalCSS}</style>
574
+ <script src="https://unpkg.com/[email protected]"></script>
575
+ <script src="/assets/js/timeago.js"></script>
576
+ </head>
577
+ <body>
578
+ <Header currentPath={url?.pathname} />
579
+ <main>{children}</main>
580
+ <Footer />
581
+ </body>
582
+ </html>
583
+ );
584
+ }
585
+ ```
586
+
587
+ - [ ] **Step 5: Create `packages/shared/ui/src/header.tsx`**
588
+
589
+ ```tsx
590
+ import { NAV_ITEMS } from "@pyrossh/config";
591
+
592
+ interface HeaderProps {
593
+ currentPath?: string;
594
+ }
595
+
596
+ export function Header({ currentPath }: HeaderProps) {
597
+ const isActive = (href: string) => currentPath?.startsWith(href) ? "true" : "false";
598
+ return (
599
+ <>
600
+ <div class="safe-area" style="background:#131618;position:sticky;top:0;padding-top:env(safe-area-inset-top)" />
601
+ <header style="background:#131618">
602
+ <div style="max-width:min(70ch,100%-4rem);margin:0 auto">
603
+ <nav style="display:flex;align-items:center;justify-content:space-between;line-height:56px">
604
+ <a href="/" style="font-size:22px;font-weight:400;color:var(--color-accent);text-decoration:none">
605
+ 木 pyrossh
606
+ </a>
607
+ <div style="display:flex;align-items:center;gap:0;font-size:22px;color:white">
608
+ <button id="theme-toggle" type="button" style="background:none;border:none;cursor:pointer;font-size:1.3rem;padding-right:12px;color:white"
609
+ onclick="document.documentElement.toggleAttribute('data-theme')">
610
+ <span style="display:var(--btn-light)">☀️</span>
611
+ <span style="display:var(--btn-dark)">🌙</span>
612
+ </button>
613
+ <span>|</span>
614
+ {NAV_ITEMS.map(item => (
615
+ <>
616
+ <a href={item.href}
617
+ aria-current={isActive(item.href)}
618
+ style={`display:inline-block;text-decoration:none;font-size:22px;line-height:56px;color:white;font-weight:400;padding:0 12px;
619
+ ${currentPath?.startsWith(item.href) ? 'text-decoration:underline;text-decoration-color:var(--color-accent);text-underline-offset:8px' : ''}`}>
620
+ {item.label}
621
+ </a>
622
+ <span>|</span>
623
+ </>
624
+ ))}
625
+ </div>
626
+ </nav>
627
+ </div>
628
+ </header>
629
+ </>
630
+ );
631
+ }
632
+ ```
633
+
634
+ - [ ] **Step 6: Create `packages/shared/ui/src/footer.tsx`**
635
+
636
+ ```tsx
637
+ export function Footer() {
638
+ return (
639
+ <>
640
+ <footer style="background:#131818;margin-top:auto">
641
+ <div style="max-width:min(70ch,100%-4rem);margin:0 auto;padding:16px 0;display:flex;flex-direction:row">
642
+ <div style="flex:1" />
643
+ <p style="display:flex;align-items:center;color:var(--color-white);font-size:1rem;margin:0">
644
+ Copyright © <current-year>{new Date().getFullYear()}</current-year>
645
+ <a href="https://pyrossh.dev" style="font-size:1rem;margin-left:0.5rem;color:var(--color-white);font-weight:500;text-underline-offset:2px">
646
+ pyrossh
647
+ </a>
648
+ </p>
649
+ </div>
650
+ </footer>
651
+ <div style="background:#131818;position:sticky;bottom:0;padding-bottom:env(safe-area-inset-bottom)" />
652
+ </>
653
+ );
654
+ }
655
+ ```
656
+
657
+ - [ ] **Step 7: Create `packages/shared/ui/src/repo-layout.tsx`**
658
+
659
+ Port the full RepoLayout.astro to JSX — repo header with description, tags, git clone command, and tab navigation.
660
+
661
+ ```tsx
662
+ import { Layout } from "./layout";
663
+ import type { RuntimeRepo } from "@pyrossh/types";
664
+
665
+ interface RepoLayoutProps {
666
+ repo: RuntimeRepo;
667
+ request: Request;
668
+ children: any;
669
+ currentTab: string;
670
+ }
671
+
672
+ export function RepoLayout({ repo, request, children, currentTab }: RepoLayoutProps) {
673
+ const { title, description, tags } = repo.data;
674
+ const url = new URL(request.url);
675
+ const pathname = url.pathname;
676
+
677
+ return (
678
+ <Layout title={`${title} — pyrossh`} description={description} request={request}>
679
+ <div class="repo" style="padding:0.5rem;background:var(--color-box-bg);font-size:0.95rem">
680
+ <div class="header" style="display:flex;flex-direction:row;align-items:baseline">
681
+ <h1 style="font-size:1.6rem;font-weight:500;margin-bottom:0.1rem;flex:1;margin:0">
682
+ <a href="/" style="color:var(--color-link)">~repos</a>
683
+ /{title}
684
+ </h1>
685
+ <div class="tags" style="margin-top:0.5rem;display:flex;flex-wrap:wrap;gap:0.25rem">
686
+ {tags.map(tag => <span class="tag" style="color:var(--color-tag);padding:0.15rem 0.5rem 0.5rem 0;font-size:0.95rem;font-weight:500">#{tag}</span>)}
687
+ </div>
688
+ </div>
689
+ <h3 style="font-size:1rem;font-weight:500">
690
+ GIT_CONFIG_PARAMETERS="'http.version=HTTP/1.1'" git clone{' '}
691
+ <a style="color:var(--color-link)">https://git.pyrossh.dev/{title}/.git</a> {title}
692
+ </h3>
693
+ <div style="font-size:0.95rem">{description}</div>
694
+ <hr style="margin-top:0.5rem;border-color:var(--color-text)" />
695
+ <div class="nav" style="display:flex;align-items:center;margin-top:0.5rem">
696
+ {["Readme", "Commits", "Files"].map(tab => {
697
+ const href = tab === "Readme" ? `/repos/${title}` : `/repos/${title}/${tab.toLowerCase()}`;
698
+ const active = currentTab === tab;
699
+ return (
700
+ <div style="padding-right:0.5rem">
701
+ <a href={href}
702
+ aria-current={active ? "true" : "false"}
703
+ hx-get={href}
704
+ hx-target="#tab-content"
705
+ hx-push-url="true"
706
+ style={`font-size:1rem;font-weight:500;padding-right:0.5rem;cursor:pointer;color:var(--color-link);
707
+ ${active ? 'text-underline-offset:4px;text-decoration:underline' : ''}`}>
708
+ {tab}
709
+ </a>
710
+ <span style="padding-right:0.5rem">|</span>
711
+ </div>
712
+ );
713
+ })}
714
+ </div>
715
+ </div>
716
+ <div id="tab-content">
717
+ {children}
718
+ </div>
719
+ </Layout>
720
+ );
721
+ }
722
+ ```
723
+
724
+ - [ ] **Step 8: Create remaining UI components**
725
+
726
+ Create `commit-entry.tsx`, `issue-card.tsx`, `file-tree.tsx`, `formatted-date.tsx`, `repo-layout.tsx`, `file-layout.tsx` — each ported from their Astro equivalents. FileLayout wraps RepoLayout with file-specific sub-navigation (Contents | History | Blame).
727
+
728
+ - [ ] **Step 9: Create `packages/shared/ui/src/index.ts` — re-exports**
729
+
730
+ Export all component functions from a single entry point.
731
+
732
+ - [ ] **Step 10: Commit**
733
+
734
+ ```bash
735
+ git add packages/shared/ui/
736
+ git commit -m "feat: add ui components package with typed-htmx JSX"
737
+ ```
738
+
739
+ ---
740
+
741
+ ### Task 5: Simple Workers — robots, rss, not-found, server-error
742
+
743
+ **Files:**
744
+ - Create: `packages/workers/robots/package.json`
745
+ - Create: `packages/workers/robots/tsconfig.json`
746
+ - Create: `packages/workers/robots/wrangler.jsonc`
747
+ - Create: `packages/workers/robots/src/index.ts`
748
+ - Create: `packages/workers/rss/package.json` + similar files
749
+ - Create: `packages/workers/not-found/` (4 files)
750
+ - Create: `packages/workers/server-error/` (4 files)
751
+
752
+ **Pattern for each worker:** package.json, tsconfig.json, wrangler.jsonc, src/index.ts(x)
753
+
754
+ - [ ] **Step 1: Create the robots.txt worker**
755
+
756
+ `packages/workers/robots/wrangler.jsonc`:
757
+ ```json
758
+ {
759
+ "name": "pyrossh-robots",
760
+ "main": "src/index.ts",
761
+ "compatibility_date": "2026-07-07",
762
+ "compatibility_flags": ["nodejs_compat"],
763
+ "workers_dev": false,
764
+ "routes": [{ "pattern": "pyrossh.dev/robots.txt", "custom_domain": true }]
765
+ }
766
+ ```
767
+
768
+ `packages/workers/robots/src/index.ts`:
769
+ ```typescript
770
+ export default {
771
+ async fetch(request: Request): Promise<Response> {
772
+ const url = new URL(request.url);
773
+ const sitemapURL = new URL("sitemap-index.xml", url.origin);
774
+ const robots = `User-agent: *\nAllow: /\n\nSitemap: ${sitemapURL.href}\n`;
775
+ return new Response(robots, {
776
+ headers: { "content-type": "text/plain" },
777
+ });
778
+ },
779
+ };
780
+ ```
781
+
782
+ - [ ] **Step 2: Create the RSS worker**
783
+
784
+ `packages/workers/rss/wrangler.jsonc`:
785
+ ```json
786
+ {
787
+ "name": "pyrossh-rss",
788
+ "main": "src/index.ts",
789
+ "compatibility_date": "2026-07-07",
790
+ "compatibility_flags": ["nodejs_compat"],
791
+ "workers_dev": false,
792
+ "routes": [{ "pattern": "pyrossh.dev/rss.xml", "custom_domain": true }],
793
+ "r2_buckets": [{ "binding": "REPOS", "bucket_name": "pyrossh-repos-prd" }]
794
+ }
795
+ ```
796
+
797
+ `packages/workers/rss/src/index.ts`:
798
+ ```typescript
799
+ import { getPosts } from "@pyrossh/core";
800
+ import type { Env } from "@pyrossh/types";
801
+ import { SITE_TITLE, SITE_DESCRIPTION, SITE_URL } from "@pyrossh/config";
802
+
803
+ export default {
804
+ async fetch(request: Request, env: Env): Promise<Response> {
805
+ const posts = await getPosts(env.REPOS);
806
+ const items = posts.map(post => `
807
+ <item>
808
+ <title><![CDATA[${post.data.title}]]></title>
809
+ <description><![CDATA[${post.data.description}]]></description>
810
+ <link>${SITE_URL}/posts/${post.id}/</link>
811
+ <guid>${SITE_URL}/posts/${post.id}/</guid>
812
+ <pubDate>${post.data.pubDate.toUTCString()}</pubDate>
813
+ </item>
814
+ `).join("\n");
815
+
816
+ const rss = `<?xml version="1.0" encoding="UTF-8"?>
817
+ <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
818
+ <channel>
819
+ <title>${SITE_TITLE}</title>
820
+ <description>${SITE_DESCRIPTION}</description>
821
+ <link>${SITE_URL}</link>
822
+ <atom:link href="${SITE_URL}/rss.xml" rel="self" type="application/rss+xml"/>
823
+ ${items}
824
+ </channel>
825
+ </rss>`;
826
+
827
+ return new Response(rss, {
828
+ headers: { "content-type": "application/rss+xml; charset=utf-8" },
829
+ });
830
+ },
831
+ };
832
+ ```
833
+
834
+ - [ ] **Step 3: Create the 404 worker**
835
+
836
+ `packages/workers/not-found/src/index.tsx`:
837
+ ```tsx
838
+ import { Layout } from "@pyrossh/ui";
839
+
840
+ export default {
841
+ async fetch(request: Request): Promise<Response> {
842
+ return new Response(
843
+ <Layout title="404 — Not Found" request={request}>
844
+ <h1>404</h1>
845
+ <p>Page not found.</p>
846
+ </Layout>,
847
+ { headers: { "content-type": "text/html" }, status: 404 }
848
+ );
849
+ },
850
+ };
851
+ ```
852
+
853
+ Route pattern: `pyrossh.dev/*` (catch-all, lowest priority).
854
+
855
+ - [ ] **Step 4: Create the 500 worker** (same pattern, returns 500 status).
856
+
857
+ - [ ] **Step 5: Install dependencies in each worker**
858
+
859
+ Each worker package.json needs `"@pyrossh/ui": "workspace:*"` and/or `"@pyrossh/core": "workspace:*"` and `"@pyrossh/types": "workspace:*"`.
860
+
861
+ ```bash
862
+ pnpm install
863
+ ```
864
+
865
+ - [ ] **Step 6: Commit**
866
+
867
+ ```bash
868
+ git add packages/workers/robots/ packages/workers/rss/ packages/workers/not-found/ packages/workers/server-error/
869
+ git commit -m "feat: add simple workers — robots, rss, 404, 500"
870
+ ```
871
+
872
+ ---
873
+
874
+ ### Task 6: Home Worker
875
+
876
+ **Files:**
877
+ - Create: `packages/workers/home/package.json`
878
+ - Create: `packages/workers/home/tsconfig.json`
879
+ - Create: `packages/workers/home/wrangler.jsonc`
880
+ - Create: `packages/workers/home/src/index.tsx`
881
+
882
+ - [ ] **Step 1: Create the home worker**
883
+
884
+ `packages/workers/home/wrangler.jsonc`:
885
+ ```json
886
+ {
887
+ "name": "pyrossh-home",
888
+ "main": "src/index.tsx",
889
+ "compatibility_date": "2026-07-07",
890
+ "compatibility_flags": ["nodejs_compat"],
891
+ "workers_dev": false,
892
+ "routes": [{ "pattern": "pyrossh.dev/", "custom_domain": true }]
893
+ }
894
+ ```
895
+
896
+ `packages/workers/home/src/index.tsx`:
897
+ ```tsx
898
+ import { Layout } from "@pyrossh/ui";
899
+ import { REPOS, TOOLS, SITE_TITLE } from "@pyrossh/config";
900
+
901
+ export default {
902
+ async fetch(request: Request): Promise<Response> {
903
+ return new Response(
904
+ <Layout title={SITE_TITLE} description="Tech Lead from Bangalore who likes to create frameworks and programming languages." request={request}>
905
+ <div>
906
+ <h1 style="font-size:1.875rem;font-weight:700;margin-bottom:1rem">Hello!</h1>
907
+ <p style="font-size:1.1rem">
908
+ <a href="https://www.linkedin.com/in/pyrossh" target="_blank" rel="noopener noreferrer" style="text-decoration:underline">
909
+ <strong>I'm</strong>
910
+ </a>
911
+ a tech lead based out of Bangalore, India...
912
+ </p>
913
+ {/* Full home page content ported from index.astro — interests, contact, tools, repos grid */}
914
+ <div style="display:grid;grid-template-columns:1fr;gap:1rem">
915
+ <section>
916
+ <h2 style="font-size:1.4rem;font-weight:600">Interests</h2>
917
+ <ul style="display:grid;grid-template-columns:auto auto auto;gap:0.5rem;text-align:center;list-style:none;padding:0;margin-top:0.5rem">
918
+ {["HTML","CSS","Javascript","S3","SQL","Bun","Astro","Tauri","Remix","Zig","K8s","Iceberg"].map(i =>
919
+ <li style="background-color:var(--color-box-bg);padding:0.25rem;font-size:1.1rem">{i}</li>
920
+ )}
921
+ </ul>
922
+ </section>
923
+ {/* contact, tools, repos sections — full port of index.astro */}
924
+ </div>
925
+ </div>
926
+ </Layout>,
927
+ { headers: { "content-type": "text/html" } }
928
+ );
929
+ },
930
+ };
931
+ ```
932
+
933
+ (Port the complete HTML structure from `src/pages/index.astro`, converting all Astro `{map(...)}` to JSX `{arr.map(...)}` and `<style>` to inline styles.)
934
+
935
+ - [ ] **Step 2: Commit**
936
+
937
+ ```bash
938
+ git add packages/workers/home/
939
+ git commit -m "feat: add home page worker"
940
+ ```
941
+
942
+ ---
943
+
944
+ ### Task 7: CV Worker
945
+
946
+ **Files:**
947
+ - Create: `packages/workers/cv/package.json` + configs
948
+ - Create: `packages/workers/cv/src/index.tsx`
949
+
950
+ Renders resume page — links to PDF download and embeds SVG inline:
951
+ ```tsx
952
+ import { Layout } from "@pyrossh/ui";
953
+ import { SITE_URL } from "@pyrossh/config";
954
+
955
+ export default {
956
+ async fetch(request: Request): Promise<Response> {
957
+ return new Response(
958
+ <Layout title="Curriculum Vitae" description="Peter John CV" request={request}>
959
+ <div>
960
+ <h1 style="display:flex;align-items:flex-start;font-weight:800;font-size:2rem;margin-bottom:1rem">
961
+ Curriculum Vitae
962
+ <a href="/assets/pdfs/resume.pdf" target="_blank" rel="noopener noreferrer" style="margin-left:0.4rem;margin-top:0.8rem;color:var(--color-link)">
963
+ ↗
964
+ </a>
965
+ </h1>
966
+ <div style="border:1px solid black">
967
+ <img src="/assets/pdfs/resume.svg" alt="Resume" style="width:100%" />
968
+ </div>
969
+ </div>
970
+ </Layout>,
971
+ { headers: { "content-type": "text/html" } }
972
+ );
973
+ },
974
+ };
975
+ ```
976
+
977
+ Route: `pyrossh.dev/cv`
978
+
979
+ - [ ] **Commit**
980
+
981
+ ---
982
+
983
+ ### Task 8: Only Bible App Workers
984
+
985
+ **Files:**
986
+ - Create: `packages/workers/only-bible-app/index/` — route `/only-bible-app`
987
+ - Create: `packages/workers/only-bible-app/privacy/` — route `/only-bible-app/privacy-policy`
988
+ - Create: `packages/workers/only-bible-app/terms/` — route `/only-bible-app/terms-and-conditions`
989
+
990
+ Each is a static page worker, porting the HTML from the corresponding `.astro` pages. No dynamic data needed.
991
+
992
+ - [ ] **Step 1: Create `packages/workers/only-bible-app/index/src/index.tsx`**
993
+
994
+ Port `src/pages/only-bible-app/index.astro` — app landing page with links to Google Play and App Store.
995
+
996
+ - [ ] **Step 2: Create privacy policy and terms workers**
997
+
998
+ Port `src/pages/only-bible-app/privacy-policy/index.astro` and `terms-and-conditions/index.astro`.
999
+
1000
+ - [ ] **Step 3: Commit**
1001
+
1002
+ ```bash
1003
+ git add packages/workers/only-bible-app/
1004
+ git commit -m "feat: add only-bible-app workers"
1005
+ ```
1006
+
1007
+ ---
1008
+
1009
+ ### Task 9: Blog Workers
1010
+
1011
+ **Files:**
1012
+ - Create: `packages/workers/posts/index/` — route `pyrossh.dev/posts`
1013
+ - Create: `packages/workers/posts/detail/` — route `pyrossh.dev/posts/*`
1014
+
1015
+ - [ ] **Step 1: Blog list worker**
1016
+
1017
+ ```tsx
1018
+ import { Layout } from "@pyrossh/ui";
1019
+ import { getPosts } from "@pyrossh/core";
1020
+ import type { Env } from "@pyrossh/types";
1021
+
1022
+ export default {
1023
+ async fetch(request: Request, env: Env): Promise<Response> {
1024
+ const posts = await getPosts(env.REPOS);
1025
+ return new Response(
1026
+ <Layout title="Posts" description="Posts" request={request}>
1027
+ <h1 style="font-weight:800;font-size:2rem">Posts</h1>
1028
+ <ul style="display:flex;flex-direction:column;list-style:none;padding:0">
1029
+ {posts.map(post => (
1030
+ <li style="display:grid;grid-template-columns:1fr;gap:0.4rem;margin-top:1.25rem;line-height:1.5rem">
1031
+ <div>
1032
+ <a href={`/posts/${post.id}`} style="font-size:1.1rem;color:var(--color-post-link);text-underline-offset:4px">
1033
+ {post.data.title}
1034
+ </a>
1035
+ <span style="font-size:13pt;display:block">{post.data.description}</span>
1036
+ </div>
1037
+ <time style="font-size:1.1rem">{post.data.pubDate.toLocaleDateString()}</time>
1038
+ </li>
1039
+ ))}
1040
+ </ul>
1041
+ </Layout>,
1042
+ { headers: { "content-type": "text/html" } }
1043
+ );
1044
+ },
1045
+ };
1046
+ ```
1047
+
1048
+ - [ ] **Step 2: Blog post detail worker**
1049
+
1050
+ ```tsx
1051
+ import { Layout } from "@pyrossh/ui";
1052
+ import { getPost } from "@pyrossh/core";
1053
+ import type { Env } from "@pyrossh/types";
1054
+
1055
+ export default {
1056
+ async fetch(request: Request, env: Env): Promise<Response> {
1057
+ const url = new URL(request.url);
1058
+ const postId = url.pathname.replace("/posts/", "").replace(/\/$/, "");
1059
+ const post = await getPost(env.REPOS, postId);
1060
+ if (!post) {
1061
+ return Response.redirect(`${url.origin}/404`, 302);
1062
+ }
1063
+ return new Response(
1064
+ <Layout title={post.data.title} description={post.data.description} request={request}>
1065
+ <article>
1066
+ <h1 style="text-align:left;font-size:1.8rem;font-weight:bold;line-height:1;margin-top:0.5rem;margin-bottom:0.1rem">{post.data.title}</h1>
1067
+ <h2 style="font-size:1.1rem;font-weight:500">{post.data.description}</h2>
1068
+ <time style="font-size:1.1rem;color:light-dark(hsl(from var(--color-text) h s 40%), hsl(from var(--color-text) h s 80%))">
1069
+ {post.data.pubDate.toLocaleDateString()}
1070
+ </time>
1071
+ <hr style="margin:0.5rem 0;border-color:var(--color-text)" />
1072
+ <div dangerouslySetInnerHTML={{ __html: post.html }} />
1073
+ </article>
1074
+ </Layout>,
1075
+ { headers: { "content-type": "text/html" } }
1076
+ );
1077
+ },
1078
+ };
1079
+ ```
1080
+
1081
+ - [ ] **Step 3: Commit**
1082
+
1083
+ ```bash
1084
+ git add packages/workers/posts/
1085
+ git commit -m "feat: add blog list and detail workers"
1086
+ ```
1087
+
1088
+ ---
1089
+
1090
+ ### Task 10: Repo Readme Worker
1091
+
1092
+ **Files:**
1093
+ - Create: `packages/workers/repos/readme/package.json` + configs
1094
+ - Create: `packages/workers/repos/readme/src/index.tsx`
1095
+
1096
+ - [ ] **Step 1: Create the repo readme worker**
1097
+
1098
+ This handles `GET /repos/{repoId}` — the broadest repos pattern. Shows the README tab.
1099
+
1100
+ ```tsx
1101
+ import { RepoLayout } from "@pyrossh/ui";
1102
+ import { getRepo, getRepoReadme, renderMarkdown } from "@pyrossh/core";
1103
+ import type { Env } from "@pyrossh/types";
1104
+
1105
+ export default {
1106
+ async fetch(request: Request, env: Env): Promise<Response> {
1107
+ const url = new URL(request.url);
1108
+ const repoId = url.pathname.replace("/repos/", "").replace(/\/$/, "");
1109
+ const repo = getRepo(repoId);
1110
+ if (!repo) return Response.redirect(`${url.origin}/404`, 302);
1111
+
1112
+ const readmeRaw = await getRepoReadme(env.REPOS, repo.id);
1113
+ const readmeHtml = readmeRaw ? await renderMarkdown(readmeRaw) : "";
1114
+
1115
+ return new Response(
1116
+ <RepoLayout repo={repo} request={request} currentTab="Readme">
1117
+ <article>
1118
+ <div dangerouslySetInnerHTML={{ __html: readmeHtml }} />
1119
+ </article>
1120
+ </RepoLayout>,
1121
+ {
1122
+ headers: { "content-type": "text/html", "Cache-Control": "public, max-age=300, s-maxage=3600" },
1123
+ }
1124
+ );
1125
+ },
1126
+ };
1127
+ ```
1128
+
1129
+ Route: `pyrossh.dev/repos/*`
1130
+
1131
+ - [ ] **Step 2: Commit**
1132
+
1133
+ ---
1134
+
1135
+ ### Task 11: Repo Commits Workers
1136
+
1137
+ **Files:**
1138
+ - Create: `packages/workers/repos/commits/index/` — route `pyrossh.dev/repos/*/commits`
1139
+ - Create: `packages/workers/repos/commits/detail/` — route `pyrossh.dev/repos/*/commits/*`
1140
+
1141
+ - [ ] **Step 1: Commits list worker**
1142
+
1143
+ Ports `src/pages/repos/[...repoId]/commits/index.astro` — lists commits from gitReader.
1144
+
1145
+ - [ ] **Step 2: Commit detail worker**
1146
+
1147
+ Ports `src/pages/repos/[...repoId]/commits/[...hash]/index.astro` — shows commit details and HTML diff via diff2html.
1148
+
1149
+ - [ ] **Step 3: Commit**
1150
+
1151
+ ---
1152
+
1153
+ ### Task 12: Repo Files Workers
1154
+
1155
+ **Files:**
1156
+ - Create: `packages/workers/repos/files/index/` — route `pyrossh.dev/repos/*/files`
1157
+ - Create: `packages/workers/repos/files/detail/` — route `pyrossh.dev/repos/*/files/*`
1158
+ - Create: `packages/workers/repos/files/history/` — route `pyrossh.dev/repos/*/files/*/history`
1159
+ - Create: `packages/workers/repos/files/blame/` — route `pyrossh.dev/repos/*/files/*/blame`
1160
+
1161
+ - [ ] **Step 1: File tree worker** — lists files from gitReader, builds file tree, renders recursive list.
1162
+
1163
+ - [ ] **Step 2: File view worker** — shows file content with syntax highlighting, image preview, or binary notice.
1164
+
1165
+ - [ ] **Step 3: File history worker** — shows commits that modified the file.
1166
+
1167
+ - [ ] **Step 4: File blame worker** — WIP, shows placeholder.
1168
+
1169
+ - [ ] **Step 5: Commit**
1170
+
1171
+ ---
1172
+
1173
+ ### Task 13: Repo Issues Workers
1174
+
1175
+ **Files:**
1176
+ - Create: `packages/workers/repos/issues/index/` — route `pyrossh.dev/repos/*/issues`
1177
+ - Create: `packages/workers/repos/issues/detail/` — route `pyrossh.dev/repos/*/issues/*`
1178
+
1179
+ - [ ] **Step 1: Issues list worker**
1180
+
1181
+ Ports `src/pages/repos/[...repoId]/issues/index.astro`. Shows open/closed counts, new issue form, and issue list. Forms use `hx-post` to submit to the issues detail worker:
1182
+
1183
+ ```tsx
1184
+ <form hx-post={`/repos/${repo.id}/issues`} hx-target="#issues-list" hx-swap="innerHTML">
1185
+ <input type="hidden" name="repoId" value={repo.id} />
1186
+ <input name="title" placeholder="Title" required />
1187
+ <textarea name="body" placeholder="Description"></textarea>
1188
+ <button type="submit">Create</button>
1189
+ </form>
1190
+ ```
1191
+
1192
+ - [ ] **Step 2: Issues detail worker**
1193
+
1194
+ Ports `src/pages/repos/[...repoId]/issues/[...issueId]/index.astro`. Shows issue body, comments, state toggle (hx-put), and comment form (hx-post). Handles both GET (page render) and POST/PUT (htmx actions):
1195
+
1196
+ ```typescript
1197
+ async fetch(request: Request, env: Env): Promise<Response> {
1198
+ const url = new URL(request.url);
1199
+ const [, repoId, , issueId] = url.pathname.split("/");
1200
+
1201
+ if (request.method === "POST") {
1202
+ // Handle issue creation or comment
1203
+ const formData = await request.formData();
1204
+ // ...
1205
+ return htmxFragment(/* updated issue list or comment thread */);
1206
+ }
1207
+ if (request.method === "PUT") {
1208
+ // Handle state toggle
1209
+ // ...
1210
+ return htmxFragment(/* updated status badge */);
1211
+ }
1212
+ // GET: render full page
1213
+ // ...
1214
+ }
1215
+ ```
1216
+
1217
+ - [ ] **Step 3: Commit**
1218
+
1219
+ ---
1220
+
1221
+ ### Task 14: Assets Worker
1222
+
1223
+ **Files:**
1224
+ - Create: `packages/workers/assets/` — serves static files (CSS, images, PDFs, icons, JS)
1225
+
1226
+ - [ ] **Step 1: Create the assets worker**
1227
+
1228
+ Serves files from the `assets/` directory. In production, this can be a static R2 bucket or a Worker that serves files from a mapped directory.
1229
+
1230
+ ```tsx
1231
+ // Simple approach: redirect to R2 bucket or serve from Worker's assets
1232
+ export default {
1233
+ async fetch(request: Request): Promise<Response> {
1234
+ const url = new URL(request.url);
1235
+ const assetPath = url.pathname.replace("/assets/", "");
1236
+ // Serve from R2 bucket, or compile-time bundled assets
1237
+ // For now, redirect to committed assets directory
1238
+ },
1239
+ };
1240
+ ```
1241
+
1242
+ Route: `pyrossh.dev/assets/*`
1243
+
1244
+ For development simplicity, assets can be served directly from the filesystem by the dev router.
1245
+
1246
+ - [ ] **Step 2: Commit**
1247
+
1248
+ ---
1249
+
1250
+ ### Task 15: Dev Router Worker
1251
+
1252
+ **Files:**
1253
+ - Create: `packages/workers/dev-router/package.json` + configs
1254
+ - Create: `packages/workers/dev-router/src/index.ts`
1255
+
1256
+ - [ ] **Step 1: Create the dev router**
1257
+
1258
+ This is a development-only Worker that imports all route handlers and dispatches by URL path:
1259
+
1260
+ ```typescript
1261
+ import home from "@pyrossh/home";
1262
+ import cv from "@pyrossh/cv";
1263
+ import blogList from "@pyrossh/posts-index";
1264
+ import blogDetail from "@pyrossh/posts-detail";
1265
+ import repoReadme from "@pyrossh/repos-readme";
1266
+ import repoCommits from "@pyrossh/repos-commits-index";
1267
+ import repoCommitDetail from "@pyrossh/repos-commits-detail";
1268
+ import repoFiles from "@pyrossh/repos-files-index";
1269
+ import repoFileDetail from "@pyrossh/repos-files-detail";
1270
+ import repoFileHistory from "@pyrossh/repos-files-history";
1271
+ import repoFileBlame from "@pyrossh/repos-files-blame";
1272
+ import repoIssues from "@pyrossh/repos-issues-index";
1273
+ import repoIssueDetail from "@pyrossh/repos-issues-detail";
1274
+ import onlyBible from "@pyrossh/only-bible-index";
1275
+ import onlyBiblePrivacy from "@pyrossh/only-bible-privacy";
1276
+ import onlyBibleTerms from "@pyrossh/only-bible-terms";
1277
+ import robots from "@pyrossh/robots";
1278
+ import rss from "@pyrossh/rss";
1279
+ import notFound from "@pyrossh/not-found";
1280
+ import serverError from "@pyrossh/server-error";
1281
+
1282
+ interface Route {
1283
+ pattern: RegExp | string;
1284
+ worker: { fetch: (request: Request, env: any, ctx: ExecutionContext) => Promise<Response> };
1285
+ }
1286
+
1287
+ const routes: Route[] = [
1288
+ { pattern: /^\/robots\.txt$/, worker: robots },
1289
+ { pattern: /^\/rss\.xml$/, worker: rss },
1290
+ { pattern: /^\/$/, worker: home },
1291
+ { pattern: /^\/cv$/, worker: cv },
1292
+ { pattern: /^\/posts\/(.+)$/, worker: blogDetail },
1293
+ { pattern: /^\/posts$/, worker: blogList },
1294
+ // Repo routes
1295
+ { pattern: /^\/repos\/[^/]+\/issues\/[^/]+$/, worker: repoIssueDetail },
1296
+ { pattern: /^\/repos\/[^/]+\/issues$/, worker: repoIssues },
1297
+ { pattern: /^\/repos\/[^/]+\/commits\/[^/]+$/, worker: repoCommitDetail },
1298
+ { pattern: /^\/repos\/[^/]+\/commits$/, worker: repoCommits },
1299
+ { pattern: /^\/repos\/[^/]+\/files\/.+\/history$/, worker: repoFileHistory },
1300
+ { pattern: /^\/repos\/[^/]+\/files\/.+\/blame$/, worker: repoFileBlame },
1301
+ { pattern: /^\/repos\/[^/]+\/files\/.+$/, worker: repoFileDetail },
1302
+ { pattern: /^\/repos\/[^/]+\/files$/, worker: repoFiles },
1303
+ { pattern: /^\/repos\/.+$/, worker: repoReadme },
1304
+ // Only Bible
1305
+ { pattern: /^\/only-bible-app\/privacy-policy$/, worker: onlyBiblePrivacy },
1306
+ { pattern: /^\/only-bible-app\/terms-and-conditions$/, worker: onlyBibleTerms },
1307
+ { pattern: /^\/only-bible-app/, worker: onlyBible },
1308
+ ];
1309
+
1310
+ // Assets — serve from local filesystem in dev
1311
+ export default {
1312
+ async fetch(request: Request, env: any, ctx: ExecutionContext): Promise<Response> {
1313
+ const url = new URL(request.url);
1314
+ for (const route of routes) {
1315
+ const match = typeof route.pattern === "string"
1316
+ ? url.pathname === route.pattern
1317
+ : route.pattern.test(url.pathname);
1318
+ if (match) {
1319
+ try {
1320
+ return await route.worker.fetch(request, env, ctx);
1321
+ } catch (err) {
1322
+ return await serverError.fetch(request, env, ctx);
1323
+ }
1324
+ }
1325
+ }
1326
+ return await notFound.fetch(request, env, ctx);
1327
+ },
1328
+ };
1329
+ ```
1330
+
1331
+ - [ ] **Step 2: Commit**
1332
+
1333
+ ---
1334
+
1335
+ ### Task 16: Root Package Scripts & Deployment
1336
+
1337
+ **Files:**
1338
+ - Modify: `package.json` (root) — add scripts
1339
+ - Create: `scripts/deploy-all.sh`
1340
+
1341
+ - [ ] **Step 1: Add root scripts**
1342
+
1343
+ ```json
1344
+ {
1345
+ "scripts": {
1346
+ "dev": "wrangler dev --config packages/workers/dev-router/wrangler.jsonc",
1347
+ "dev:home": "wrangler dev --config packages/workers/home/wrangler.jsonc",
1348
+ "deploy:all": "bash scripts/deploy-all.sh",
1349
+ "deploy:worker": "wrangler deploy --config",
1350
+ "typecheck": "tsc --noEmit"
1351
+ }
1352
+ }
1353
+ ```
1354
+
1355
+ - [ ] **Step 2: Create `scripts/deploy-all.sh`**
1356
+
1357
+ ```bash
1358
+ #!/bin/bash
1359
+ set -euo pipefail
1360
+
1361
+ find packages/workers -name 'wrangler.jsonc' -not -path '*/dev-router/*' | while read -r config; do
1362
+ echo "Deploying: $config"
1363
+ wrangler deploy --config "$config"
1364
+ done
1365
+ ```
1366
+
1367
+ - [ ] **Step 3: Commit**
1368
+
1369
+ ```bash
1370
+ git add package.json scripts/
1371
+ git commit -m "chore: add root scripts and deploy script"
1372
+ ```
1373
+
1374
+ ---
1375
+
1376
+ ### Task 17: Remove Old Astro Source
1377
+
1378
+ **Files:**
1379
+ - Delete: entire `src/` directory
1380
+ - Delete: `astro.config.mjs`
1381
+ - Delete: `ec.config.mjs`
1382
+ - Delete: `.astro/` directory
1383
+ - Delete: Astro-specific dependencies from root `package.json`
1384
+ - Modify: `wrangler.toml` — remove Astro-specific config
1385
+
1386
+ - [ ] **Step 1: Remove old source**
1387
+
1388
+ ```bash
1389
+ rm -rf src/ .astro/
1390
+ git rm -r src/ .astro/
1391
+ ```
1392
+
1393
+ - [ ] **Step 2: Update root `package.json`**
1394
+
1395
+ Remove dependencies: `astro`, `@astrojs/*`, `astro-*`, `unplugin-icons`, `@iconify-json/*`, `astro-color-scheme`, etc.
1396
+
1397
+ - [ ] **Step 3: Clean up root `wrangler.toml`**
1398
+
1399
+ Keep only zone/account config, remove the `[assets]` block and Astro-specific entries.
1400
+
1401
+ - [ ] **Step 4: Commit**
1402
+
1403
+ ```bash
1404
+ git add -A
1405
+ git commit -m "feat: remove old Astro source after full migration"
1406
+ ```
docs/superpowers/specs/2026-07-07-rewrite-typed-htmx-workers-design.md ADDED
@@ -0,0 +1,357 @@
1
+ # Rewrite pyrossh.dev with Cloudflare Workers + typed-htmx + monorepo
2
+
3
+ ## Motivation
4
+
5
+ Replace Astro 6 with a purpose-built architecture: individual Cloudflare Workers (one per URL pattern) generating HTML via typed-htmx/typed-html JSX, orchestrated via Cloudflare Routes for path-based routing. This provides complete control over the rendering pipeline, minimal bundle sizes per endpoint, and a clean monorepo structure that mirrors the URL hierarchy.
6
+
7
+ ## Scope
8
+
9
+ Rewrite the entire `src/` directory into a pnpm workspaces monorepo. Migrate ~20 routes into individual Worker packages. Replace Astro components with typed-htmx JSX templates. Keep existing business logic (git reader, markdown pipeline, content parser, git-bug issues) as shared packages. Remove Astro, its adapter, and all Astro-specific dependencies.
10
+
11
+ ## Architecture
12
+
13
+ ### Routing
14
+
15
+ Each Worker declares its route pattern in `wrangler.jsonc`. Cloudflare Routes match requests to the correct Worker at the edge:
16
+
17
+ ```
18
+ pyrossh.dev/cv → cv Worker
19
+ pyrossh.dev/posts → posts/index Worker
20
+ pyrossh.dev/posts/* → posts/detail Worker
21
+ pyrossh.dev/repos/* → repos/readme Worker
22
+ pyrossh.dev/repos/*/commits → repos/commits/index Worker
23
+ pyrossh.dev/repos/*/commits/* → repos/commits/detail Worker
24
+ pyrossh.dev/repos/*/files → repos/files/index Worker
25
+ pyrossh.dev/repos/*/files/* → repos/files/detail Worker
26
+ pyrossh.dev/repos/*/files/*/history → repos/files/history Worker
27
+ pyrossh.dev/repos/*/files/*/blame → repos/files/blame Worker
28
+ pyrossh.dev/repos/*/issues → repos/issues/index Worker
29
+ pyrossh.dev/repos/*/issues/* → repos/issues/detail Worker
30
+ pyrossh.dev/only-bible-app → only-bible-app/index Worker
31
+ pyrossh.dev/only-bible-app/privacy-policy → only-bible-app/privacy Worker
32
+ pyrossh.dev/only-bible-app/terms-and-conditions → only-bible-app/terms Worker
33
+ pyrossh.dev/robots.txt → robots Worker
34
+ pyrossh.dev/rss.xml → rss Worker
35
+ pyrossh.dev/assets/* → assets Worker (CSS, images, PDFs)
36
+ ```
37
+
38
+ More specific patterns match before broader ones (Cloudflare edge behavior).
39
+
40
+ In development, a single dev-router Worker imports all route handlers as modules and dispatches by URL path — no need to run 18 separate `wrangler dev` instances.
41
+
42
+ ### Monorepo Structure
43
+
44
+ ```
45
+ pyrossh.dev/
46
+ ├── packages/
47
+ │ ├── shared/
48
+ │ │ ├── config/ # Constants, SITE_TITLE, REPOS list, TOOLS list
49
+ │ │ │ ├── package.json # @pyrossh/config
50
+ │ │ │ └── src/index.ts
51
+ │ │ ├── core/ # Git reader, markdown pipeline, content utils, R2 IO
52
+ │ │ │ ├── package.json # @pyrossh/core
53
+ │ │ │ └── src/
54
+ │ │ │ ├── gitReader.ts # Bare git pack parser (from existing)
55
+ │ │ │ ├── markdown.ts # Unified pipeline (from existing)
56
+ │ │ │ ├── content.ts # Blog content reader (from existing)
57
+ │ │ │ ├── gitBug.ts # Issue CRUD on R2 (from existing)
58
+ │ │ │ ├── files.ts # File tree builder (from existing)
59
+ │ │ │ └── repoContent.ts# README reader (from existing)
60
+ │ │ ├── ui/ # Layouts, components, CSS as typed-htmx JSX
61
+ │ │ │ ├── package.json # @pyrossh/ui
62
+ │ │ │ └── src/
63
+ │ │ │ ├── layout.tsx # Base HTML shell, head, header, footer
64
+ │ │ │ ├── header.tsx # Nav bar with active link detection
65
+ │ │ │ ├── footer.tsx # Copyright footer
66
+ │ │ │ ├── repo-nav.tsx # Repo sub-navigation tabs
67
+ │ │ │ ├── file-tree.tsx # Recursive file tree component
68
+ │ │ │ ├── issue-card.tsx# Issue display card
69
+ │ │ │ ├── commit-entry.tsx # Commit list item
70
+ │ │ │ ├── formatted-date.tsx # Date formatting
71
+ │ │ │ └── css.ts # CSS string export for inlining
72
+ │ │ └── types/ # Shared TypeScript types
73
+ │ │ ├── package.json # @pyrossh/types
74
+ │ │ └── src/
75
+ │ │ ├── env.d.ts # R2 binding types, Worker env interfaces
76
+ │ │ └── index.ts # Shared domain types
77
+ │ └── workers/
78
+ │ ├── home/
79
+ │ │ ├── package.json # @pyrossh/home
80
+ │ │ ├── wrangler.jsonc # route: pyrossh.dev/
81
+ │ │ ├── tsconfig.json
82
+ │ │ └── src/index.tsx
83
+ │ ├── cv/
84
+ │ ├── posts/
85
+ │ │ ├── index/
86
+ │ │ └── detail/
87
+ │ ├── repos/
88
+ │ │ ├── readme/
89
+ │ │ ├── commits/
90
+ │ │ │ ├── index/
91
+ │ │ │ └── detail/
92
+ │ │ ├── files/
93
+ │ │ │ ├── index/
94
+ │ │ │ ├── detail/
95
+ │ │ │ ├── history/
96
+ │ │ │ └── blame/
97
+ │ │ └── issues/
98
+ │ │ ├── index/
99
+ │ │ └── detail/
100
+ │ ├── only-bible-app/
101
+ │ │ ├── index/
102
+ │ │ ├── privacy/
103
+ │ │ └── terms/
104
+ │ ├── robots/
105
+ │ ├── rss/
106
+ │ ├── assets/ # Static files: CSS, resume PDF/SVG, images
107
+ │ ├── not-found/ # 404
108
+ │ ├── server-error/ # 500
109
+ │ └── dev-router/ # Dev-only: imports & dispatches to all workers
110
+ ├── pnpm-workspace.yaml
111
+ ├── tsconfig.base.json
112
+ ├── package.json # Root scripts
113
+ └── wrangler.toml # Zone-level config (optional)
114
+ ```
115
+
116
+ ### Shared Packages
117
+
118
+ **`@pyrossh/config`** — Pure constants, no dependencies. Exports `SITE_TITLE`, `SITE_DESCRIPTION`, `REPOS` array, `TOOLS` array, menu items.
119
+
120
+ **`@pyrossh/core`** — All domain logic migrated from existing `src/utils/`:
121
+ - `gitReader.ts` — Identical port, uses R2 binding interface
122
+ - `markdown.ts` — Same unified pipeline (remark → rehype → expressive-code → stringify)
123
+ - `content.ts` — Reads blog markdown from content directory in repo
124
+ - `gitBug.ts` — Issue CRUD with spamscanner
125
+ - `files.ts` — File tree builder with vscode-icons
126
+ - `repoContent.ts` — README fetcher
127
+
128
+ All core functions accept `R2Bucket` as a parameter (DI-style), keeping them testable and R2-implementation-agnostic.
129
+
130
+ **`@pyrossh/ui`** — typed-htmx/typed-html JSX components:
131
+ ```tsx
132
+ // Example: layout.tsx
133
+ import type { HtmlChildren } from 'typed-htmx/typed-html'
134
+ import { css } from './css'
135
+
136
+ export function Layout({ title, children, request }: {
137
+ title: string
138
+ children: HtmlChildren
139
+ request?: Request
140
+ }) {
141
+ return (
142
+ <html lang="en">
143
+ <head>
144
+ <meta charset="UTF-8" />
145
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
146
+ <title>{title}</title>
147
+ <style>{css}</style>
148
+ <script src="https://unpkg.com/htmx.org@2"></script>
149
+ </head>
150
+ <body>
151
+ <Header currentPath={request ? new URL(request.url).pathname : ''} />
152
+ <main>{children}</main>
153
+ <Footer />
154
+ </body>
155
+ </html>
156
+ )
157
+ }
158
+ ```
159
+
160
+ **`@pyrossh/types`** — Env interfaces:
161
+ ```typescript
162
+ export interface Env {
163
+ REPOS: R2Bucket
164
+ CONTENT: R2Bucket
165
+ }
166
+ ```
167
+
168
+ ### Each Worker Pattern
169
+
170
+ Every worker follows the same structure:
171
+
172
+ ```tsx
173
+ // packages/workers/cv/src/index.tsx
174
+ import { Layout } from '@pyrossh/ui'
175
+
176
+ export default {
177
+ async fetch(request: Request, env: Env, ctx: ExecutionContext) {
178
+ return new Response(
179
+ <Layout title="CV - pyrossh" request={request}>
180
+ <h1>Curriculum Vitae</h1>
181
+ {/* CV content */}
182
+ </Layout>,
183
+ { headers: { 'content-type': 'text/html' } }
184
+ )
185
+ }
186
+ }
187
+ ```
188
+
189
+ ### Development Workflow
190
+
191
+ **Dev router** (`packages/workers/dev-router/src/index.tsx`) — imports all workers and dispatches by URL path:
192
+
193
+ ```tsx
194
+ import home from '@pyrossh/home'
195
+ import cv from '@pyrossh/cv'
196
+ import blogList from '@pyrossh/posts-index'
197
+ // ...
198
+
199
+ const routes = [
200
+ { pattern: '/', worker: home },
201
+ { pattern: '/cv', worker: cv },
202
+ { pattern: '/posts', worker: blogList },
203
+ { pattern: new URLPattern({ pathname: '/posts/:slug' }), worker: blogPost },
204
+ // ...
205
+ ]
206
+
207
+ export default {
208
+ async fetch(request: Request, env: Env, ctx: ExecutionContext) {
209
+ const url = new URL(request.url)
210
+ for (const route of routes) {
211
+ const match = route.pattern instanceof URLPattern
212
+ ? route.pattern.exec(url)
213
+ : url.pathname === route.pattern
214
+ if (match) return route.worker.fetch(request, env, ctx)
215
+ }
216
+ return notFound.fetch(request, env, ctx)
217
+ }
218
+ }
219
+ ```
220
+
221
+ Run with: `wrangler dev --config packages/workers/dev-router/wrangler.jsonc`
222
+
223
+ For focused development on a single worker: `cd packages/workers/home && wrangler dev`.
224
+
225
+ ### htmx Interactivity
226
+
227
+ htmx handles all dynamic UI without custom JavaScript:
228
+
229
+ | Feature | htmx approach |
230
+ |---------|--------------|
231
+ | Issue creation | `form hx-post="/repos/{repo}/issues"` → server returns updated issue list fragment |
232
+ | Issue commenting | `form hx-post="/repos/{repo}/issues/{id}/comments"` → server returns comment thread |
233
+ | Issue state toggle | `button hx-put="/repos/{repo}/issues/{id}/state"` → server returns badge |
234
+ | File tree expand | `span hx-get="/repos/{repo}/files/{dir}?expanded=true" hx-target="next ul"` |
235
+ | Repo tab nav | `a hx-get="/repos/{repo}/{tab}" hx-target="#tab-content" hx-push-url="true"` |
236
+ | Relative timestamps | `timeago` custom element (tiny client script) |
237
+
238
+ Each htmx request hits the same Worker (for that route pattern), which returns an HTML fragment instead of a full page when `HX-Request` header is present.
239
+
240
+ ### CSS Strategy
241
+
242
+ A single CSS file in the assets Worker, served at `pyrossh.dev/assets/main.css`:
243
+ - Uses `light-dark()` for theme support
244
+ - CSS custom properties for colors, spacing, typography
245
+ - Same visual design as current site
246
+
247
+ Each Worker's Layout component links to it. The assets Worker also serves images, resume PDF/SVG, and app store icons.
248
+
249
+ Alternatively, CSS can be inlined as a string in `@pyrossh/ui/css.ts` for zero external requests (preferred for initial load speed). Theme toggle uses a small inline script + CSS class on `<html>`.
250
+
251
+ ### Data Flow
252
+
253
+ ```
254
+ Browser ──GET /repos/rust-embed──▶ Cloudflare Edge
255
+ │
256
+ ▼
257
+ Route match: repos/readme Worker
258
+ │
259
+ ▼
260
+ repos/readme/src/index.tsx
261
+ │
262
+ ┌─────────────┼─────────────┐
263
+ ▼ ▼ ▼
264
+ @pyrossh/core @pyrossh/ui @pyrossh/config
265
+ (git reader, (Layout, (constants)
266
+ markdown) components)
267
+ │
268
+ ▼
269
+ R2 Bucket (REPOS)
270
+ │
271
+ ▼
272
+ HTML Response (with htmx attributes)
273
+ ```
274
+
275
+ ### Deployment
276
+
277
+ Each worker deployed independently via `wrangler deploy`:
278
+ ```bash
279
+ wrangler deploy --config packages/workers/cv/wrangler.jsonc
280
+ ```
281
+
282
+ All workers share the same `zone_id` and `route` patterns in their respective `wrangler.jsonc`. Deployment can be scripted:
283
+ ```bash
284
+ # Deploy all workers
285
+ for dir in packages/workers/*/ packages/workers/*/*/; do
286
+ if [ -f "$dir/wrangler.jsonc" ]; then
287
+ wrangler deploy --config "$dir/wrangler.jsonc"
288
+ fi
289
+ done
290
+ ```
291
+
292
+ Or individual workers can be deployed on demand.
293
+
294
+ ### Error Handling
295
+
296
+ - `not-found` Worker: route `pyrossh.dev/*` (catch-all at Cloudflare edge). Renders 404 page.
297
+ - `server-error` Worker: not a route, but a shared error handler each worker uses in try/catch.
298
+
299
+ ### Testing
300
+
301
+ Existing Playwright tests will be adapted to target the dev router (localhost:8787). Tests remain the same — they verify page content and navigation. Unit tests for `@pyrossh/core` can use Vitest.
302
+
303
+ ## Files to Create
304
+
305
+ Root config:
306
+ - `pnpm-workspace.yaml` — add `packages/workers/**`
307
+ - `tsconfig.base.json` — shared TS config with `jsx: "react-jsx"`, `jsxImportSource: "typed-htmx/typed-html"`
308
+ - `package.json` — root scripts: `dev`, `dev:worker`, `build`, `deploy:all`, `deploy:worker`
309
+
310
+ Shared packages (created with `package.json`, `tsconfig.json`, `src/index.ts`):
311
+ - `packages/shared/config/src/index.ts`
312
+ - `packages/shared/core/src/gitReader.ts`, `markdown.ts`, `content.ts`, `gitBug.ts`, `files.ts`, `repoContent.ts` (ported from existing `src/utils/`)
313
+ - `packages/shared/ui/src/layout.tsx`, `header.tsx`, `footer.tsx`, `repo-nav.tsx`, `file-tree.tsx`, `issue-card.tsx`, `commit-entry.tsx`, `formatted-date.tsx`, `css.ts`
314
+ - `packages/shared/types/src/env.d.ts`, `index.ts`
315
+
316
+ Worker packages (each with `package.json`, `wrangler.jsonc`, `tsconfig.json`, `src/index.tsx`):
317
+ - All 18 workers listed in the Monorepo Structure section above
318
+ - `packages/workers/dev-router/` — dev-only router
319
+
320
+ ## Files to Remove
321
+
322
+ After migration:
323
+ - `src/` directory (entire Astro source)
324
+ - `astro.config.mjs`
325
+ - `vite.config.ts` (if not needed for workers)
326
+ - `ec.config.mjs`
327
+ - `.astro/` directory
328
+ - Astro-specific dependencies from root `package.json`:
329
+ - `astro`
330
+ - `@astrojs/cloudflare`
331
+ - `@astrojs/check`
332
+ - `@astrojs/rss`
333
+ - `@astrojs/sitemap`
334
+ - `astro-color-scheme`
335
+ - `astro-expressive-code`
336
+ - `unplugin-icons` (icons per-worker or in assets Worker)
337
+ - `@iconify-json/*` (if only used in Astro components)
338
+
339
+ ## Dependencies to Add
340
+
341
+ Root / shared:
342
+ - `typed-htmx`
343
+ - `htmx.org` (client script, served from assets Worker or CDN)
344
+ - `wrangler` (already present)
345
+ - `concurrently` (for dev scripts if needed)
346
+ - `typescript` (already present)
347
+
348
+ `@pyrossh/core`:
349
+ - `unified`, `remark-parse`, `remark-rehype`, `rehype-stringify`, `rehype-expressive-code`
350
+ - `gray-matter`
351
+ - `diff`
352
+ - `spamscanner`
353
+ - `pretty-bytes`
354
+ - `diff2html`
355
+
356
+ `@pyrossh/ui`:
357
+ - `typed-htmx`
package.json CHANGED
@@ -20,7 +20,6 @@
20
20
  "rehype-stringify": "^10.0.1",
21
21
  "remark-parse": "^11.0.0",
22
22
  "remark-rehype": "^11.1.2",
23
- "spamscanner": "^6.1.5",
24
23
  "timeago.js": "^4.0.2",
25
24
  "typed-htmx": "^0.3.1",
26
25
  "unified": "^11.0.5"
packages/shared/core/package.json CHANGED
@@ -16,7 +16,6 @@
16
16
  "rehype-stringify": "^10.0.1",
17
17
  "remark-parse": "^11.0.0",
18
18
  "remark-rehype": "^11.1.2",
19
- "spamscanner": "^6.1.5",
20
19
  "unified": "^11.0.5",
21
20
  "vscode-icons": "https://github.com/vscode-icons/vscode-icons.git"
22
21
  }
packages/workers/dev-router/wrangler.jsonc CHANGED
@@ -4,5 +4,5 @@
4
4
  "compatibility_date": "2026-07-07",
5
5
  "compatibility_flags": ["nodejs_compat"],
6
6
  "workers_dev": true,
7
- "r2_buckets": [{ "binding": "REPOS", "bucket_name": "pyrossh-repos-prd" }],
7
+ "r2_buckets": [{ "binding": "REPOS", "bucket_name": "pyrossh-repos-prd" }]
8
8
  }
pnpm-workspace.yaml CHANGED
@@ -7,11 +7,11 @@ catalog:
7
7
  vite-plus: ^0.2.2
8
8
 
9
9
  allowBuilds:
10
- "@tensorflow/tfjs-node": set this to true or false
10
+ "@tensorflow/tfjs-node": false
11
- core-js: set this to true or false
11
+ core-js: false
12
- es5-ext: set this to true or false
12
+ es5-ext: false
13
- esbuild: set this to true or false
13
+ esbuild: true
14
- iconv: set this to true or false
14
+ iconv: false
15
- re2: set this to true or false
15
+ re2: false
16
- sharp: set this to true or false
16
+ sharp: false
17
- workerd: set this to true or false
17
+ workerd: true