website

#astro#js#html#css

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

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


58fbdf5pyrossh 2026-07-08T19:37:29+05:30
docs: add implementation plan for Hono rewrite
docs/superpowers/plans/2026-07-08-honox-rewrite.md ADDED
@@ -0,0 +1,1279 @@
1
+ # Hono Rewrite 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 a 22-package monorepo to a single Hono application with `hono/html` for templating, `s3mini` for storage, and HTMX for interactivity.
6
+
7
+ **Architecture:** Single `src/` directory. Hono app with all routes in `src/index.ts`. Components in `src/components/`. Business logic in `src/lib/`. Static assets served by wrangler's `assets.directory`. S3-compatible storage via `s3mini` from env vars (no R2 bindings).
8
+
9
+ **Tech Stack:** Hono (html, jsx, css), s3mini, HTMX, wrangler
10
+
11
+ ## Global Constraints
12
+
13
+ - `hono/html` tagged templates for HTML rendering (no JSX, no `.tsx` files)
14
+ - All storage via `s3mini` S3 client — no R2 bucket bindings
15
+ - No monorepo, no pnpm workspaces, no shared packages
16
+ - TypeScript strict mode, `"moduleResolution": "bundler"`
17
+ - All source files are `.ts`
18
+ - `wrangler.jsonc` uses `assets.directory` for static files
19
+ - Config values and types exported from `src/config.ts` and `src/types.ts`
20
+ - `hono/html` tagged templates: `import { html } from 'hono/html'` — use `${expr}` for interpolation, arrays auto-join
21
+ - The `Layout` wrapper function is defined in `src/index.ts`
22
+ - R2Bucket parameters change to `S3mini` from `s3mini` package
23
+ - `bucket.get(key)` → `s3.getObject(key)` (returns string | null)
24
+ - `bucket.get(key).bytes()` → `new Uint8Array(await s3.getObjectArrayBuffer(key) ?? new ArrayBuffer(0))`
25
+ - `bucket.get(key).text()` → `s3.getObject(key)`
26
+ - `bucket.list({prefix})` → `s3.listObjects('/', prefix)` (auto-paginated)
27
+ - `bucket.put(key, data, opts)` → `s3.putObject(key, data, contentType)`
28
+ - `bucket.get(key, {range})` → `s3.getObjectRaw(key, false, offset, end)` then `.arrayBuffer()`
29
+
30
+ ---
31
+
32
+ ### Task 1: Scaffold + Foundation (config, types, s3)
33
+
34
+ **Files:**
35
+ - Create: `package.json`
36
+ - Create: `tsconfig.json`
37
+ - Create: `wrangler.jsonc`
38
+ - Create: `src/config.ts`
39
+ - Create: `src/types.ts`
40
+ - Create: `src/s3.ts`
41
+
42
+ **Interfaces:**
43
+ - Consumes: nothing
44
+ - Produces: `SITE_TITLE`, `REPOS`, `TOOLS`, `NAV_ITEMS`, `SITE_URL`, `SITE_DESCRIPTION` from `config.ts`; `RuntimePost`, `RuntimeRepo`, `Commit`, `FileEntry`, `FileNode`, `GitBugIssue`, `GitBugComment`, `GitBugIssuesResult`, `GitBugIssueState` from `types.ts`; `getS3(): S3mini` from `s3.ts`
45
+
46
+ - [ ] **Step 1: Create `package.json`**
47
+
48
+ ```json
49
+ {
50
+ "name": "pyrossh-dev",
51
+ "type": "module",
52
+ "scripts": {
53
+ "dev": "wrangler dev",
54
+ "deploy": "wrangler deploy",
55
+ "cf-typegen": "wrangler types"
56
+ },
57
+ "dependencies": {
58
+ "hono": "^4.7.5",
59
+ "s3mini": "^0.9.5"
60
+ },
61
+ "devDependencies": {
62
+ "@cloudflare/workers-types": "^4.20250214.0",
63
+ "wrangler": "^4.0.0",
64
+ "typescript": "^5.7.0"
65
+ }
66
+ }
67
+ ```
68
+
69
+ - [ ] **Step 2: Create `tsconfig.json`**
70
+
71
+ ```json
72
+ {
73
+ "compilerOptions": {
74
+ "target": "ESNext",
75
+ "module": "ESNext",
76
+ "moduleResolution": "bundler",
77
+ "strict": true,
78
+ "skipLibCheck": true,
79
+ "noEmit": true,
80
+ "isolatedModules": true,
81
+ "types": ["@cloudflare/workers-types"]
82
+ },
83
+ "include": ["src"]
84
+ }
85
+ ```
86
+
87
+ No JSX config needed — `hono/html` uses tagged template literals, not JSX.
88
+
89
+ - [ ] **Step 3: Create `wrangler.jsonc`**
90
+
91
+ ```json
92
+ {
93
+ "$schema": "node_modules/wrangler/config-schema.json",
94
+ "name": "pyrossh-website",
95
+ "main": "src/index.ts",
96
+ "compatibility_date": "2026-07-07",
97
+ "compatibility_flags": ["nodejs_compat"],
98
+ "assets": { "directory": "assets" },
99
+ "vars": {
100
+ "S3_ENDPOINT": "https://account-id.r2.cloudflarestorage.com/pyrossh-repos-prd",
101
+ "S3_REGION": "auto"
102
+ },
103
+ "routes": [{ "pattern": "pyrossh.dev", "custom_domain": true }]
104
+ }
105
+ ```
106
+
107
+ Replace `account-id` with actual account ID. `S3_ACCESS_KEY_ID` and `S3_SECRET_ACCESS_KEY` are set as secrets.
108
+
109
+ - [ ] **Step 4: Create `src/config.ts`**
110
+
111
+ Copy verbatim from `packages/shared/config/src/index.ts`. Same exports: `SITE_TITLE`, `SITE_DESCRIPTION`, `REPOS`, `TOOLS`, `NAV_ITEMS`, `SITE_URL`.
112
+
113
+ ```bash
114
+ cp packages/shared/config/src/index.ts src/config.ts
115
+ ```
116
+
117
+ - [ ] **Step 5: Create `src/types.ts`**
118
+
119
+ Copy from `packages/shared/types/src/index.ts`, removing the `Env` export (no R2 binding):
120
+
121
+ ```typescript
122
+ export interface Commit {
123
+ hash: string;
124
+ author_name: string;
125
+ date: string;
126
+ message: string;
127
+ body?: string;
128
+ }
129
+
130
+ export interface FileEntry {
131
+ name: string;
132
+ size: number;
133
+ ext: string;
134
+ absolutePath: string;
135
+ }
136
+
137
+ export interface FileNode {
138
+ name: string;
139
+ path: string;
140
+ size: number;
141
+ ext: string;
142
+ absolutePath: string;
143
+ isDirectory: boolean;
144
+ children?: FileNode[];
145
+ }
146
+
147
+ export interface RuntimeRepo {
148
+ id: string;
149
+ data: {
150
+ title: string;
151
+ description: string;
152
+ tags: string[];
153
+ };
154
+ }
155
+
156
+ export interface RuntimePost {
157
+ id: string;
158
+ data: {
159
+ title: string;
160
+ description: string;
161
+ pubDate: Date;
162
+ updatedDate?: Date;
163
+ heroImage?: string;
164
+ };
165
+ body: string;
166
+ html: string;
167
+ }
168
+
169
+ export type GitBugIssueState = "open" | "closed";
170
+
171
+ export interface GitBugIssue {
172
+ id: string;
173
+ title: string;
174
+ state: GitBugIssueState;
175
+ author?: string;
176
+ createdAt: string;
177
+ updatedAt: string;
178
+ labels: string[];
179
+ body?: string;
180
+ comments: GitBugComment[];
181
+ }
182
+
183
+ export interface GitBugComment {
184
+ id: string;
185
+ author?: string;
186
+ createdAt: string;
187
+ body: string;
188
+ }
189
+
190
+ export interface GitBugIssuesResult {
191
+ issues: GitBugIssue[];
192
+ isAvailable: boolean;
193
+ }
194
+ ```
195
+
196
+ - [ ] **Step 6: Create `src/s3.ts`**
197
+
198
+ ```typescript
199
+ import { S3mini } from 's3mini'
200
+
201
+ let _s3: S3mini | null = null
202
+
203
+ export const getS3 = (): S3mini => {
204
+ if (!_s3) {
205
+ _s3 = new S3mini({
206
+ accessKeyId: process.env.S3_ACCESS_KEY_ID!,
207
+ secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!,
208
+ endpoint: process.env.S3_ENDPOINT!,
209
+ region: process.env.S3_REGION ?? 'auto',
210
+ })
211
+ }
212
+ return _s3
213
+ }
214
+ ```
215
+
216
+ - [ ] **Step 7: Commit**
217
+
218
+ ```bash
219
+ git add package.json tsconfig.json wrangler.jsonc src/config.ts src/types.ts src/s3.ts
220
+ git commit -m "feat: scaffold Hono app with config, types, and S3 client"
221
+ ```
222
+
223
+ ---
224
+
225
+ ### Task 2: Port Core Libraries
226
+
227
+ **Files:**
228
+ - Create: `src/lib/markdown.ts`
229
+ - Create: `src/lib/repoContent.ts`
230
+ - Create: `src/lib/content.ts`
231
+ - Create: `src/lib/files.ts`
232
+ - Create: `src/lib/gitBug.ts`
233
+ - Create: `src/lib/gitReader.ts`
234
+
235
+ **Interfaces:**
236
+ - Consumes: `getS3()`, `RuntimePost`, `RuntimeRepo`, `FileNode`, etc., `S3mini` type
237
+ - Produces: `renderMarkdown(md)`, `getPosts(s3)`, `getPost(s3, id)`, `getRepoReadme(s3, repoId)`, `getRepos()`, `getRepo(id)`, `getCommits(s3, repoId)`, `getFiles(s3, repoId)`, `getFileHistory(s3, repoId, path)`, `getFileContentData(s3, repoId, path)`, `generateHTMLDiff(s3, repoId, hash)`, `buildFileTree`, `sortChildren`, `resolveFolderIcon`, `resolveFileIcon`, `getGitBugIssues(s3, repoId)`, `getGitBugIssue(s3, repoId, id)`, `createGitBugIssue(s3, repoId, data)`, `addGitBugIssueComment(s3, repoId, id, data)`, `setGitBugIssueState(s3, repoId, id, state, author)`
238
+
239
+ - [ ] **Step 1: Create `src/lib/markdown.ts`**
240
+
241
+ Copy from `packages/shared/core/src/markdown.ts` verbatim — no S3 dependency. Also add the `@pyrossh/core` dependencies to package.json (`unified`, `remark-parse`, `remark-rehype`, `rehype-stringify`, `rehype-expressive-code`, `gray-matter`).
242
+
243
+ ```bash
244
+ cp packages/shared/core/src/markdown.ts src/lib/markdown.ts
245
+ ```
246
+
247
+ Add these to package.json dependencies (copied from current root package.json):
248
+ ```json
249
+ "dependencies": {
250
+ "hono": "^4.7.5",
251
+ "s3mini": "^0.9.5",
252
+ "gray-matter": "^4.0.3",
253
+ "unified": "^11.0.0",
254
+ "remark-parse": "^11.0.0",
255
+ "remark-rehype": "^11.1.2",
256
+ "rehype-stringify": "^10.0.1",
257
+ "rehype-expressive-code": "^0.44.0",
258
+ "diff": "^9.0.0",
259
+ "pretty-bytes": "^7.1.0",
260
+ "diff2html": "^3.4.52"
261
+ }
262
+ ```
263
+
264
+ - [ ] **Step 2: Create `src/lib/repoContent.ts`**
265
+
266
+ Replace `R2Bucket` with `S3mini`:
267
+
268
+ ```typescript
269
+ import { S3mini } from 's3mini'
270
+
271
+ export const getRepoReadme = async (
272
+ s3: S3mini,
273
+ repoId: string,
274
+ ): Promise<string | undefined> => {
275
+ return (await s3.getObject(`${repoId}/README.md`)) ?? undefined
276
+ }
277
+ ```
278
+
279
+ - [ ] **Step 3: Create `src/lib/files.ts`**
280
+
281
+ Copy from `packages/shared/core/src/files.ts` verbatim. No S3 dependency — only depends on `vscode-icons` for icon resolution. File tree logic is pure computation.
282
+
283
+ ```bash
284
+ cp packages/shared/core/src/files.ts src/lib/files.ts
285
+ ```
286
+
287
+ - [ ] **Step 4: Create `src/lib/content.ts`**
288
+
289
+ Replace `R2Bucket` with `S3mini`, change `bucket.list()` and `bucket.get()` to s3mini equivalents:
290
+
291
+ ```typescript
292
+ import matter from 'gray-matter'
293
+ import { S3mini } from 's3mini'
294
+ import type { RuntimePost } from '../types'
295
+ import { renderMarkdown } from './markdown'
296
+
297
+ const toDate = (value: unknown) => (value instanceof Date ? value : new Date(String(value)))
298
+
299
+ const CONTENT_PREFIX = 'content/'
300
+
301
+ export const getPosts = async (s3: S3mini): Promise<RuntimePost[]> => {
302
+ const objects = await s3.listObjects('/', CONTENT_PREFIX)
303
+ if (!objects) return []
304
+ const posts: RuntimePost[] = []
305
+
306
+ for (const obj of objects) {
307
+ if (!obj.Key.endsWith('.md') && !obj.Key.endsWith('.mdx')) continue
308
+ const source = await s3.getObject(obj.Key)
309
+ if (!source) continue
310
+ const parsed = matter(source)
311
+ const id = obj.Key.replace(CONTENT_PREFIX, '').replace(/\.(md|mdx)$/, '')
312
+
313
+ posts.push({
314
+ id,
315
+ data: {
316
+ title: String(parsed.data.title ?? ''),
317
+ description: String(parsed.data.description ?? ''),
318
+ pubDate: toDate(parsed.data.pubDate),
319
+ updatedDate: parsed.data.updatedDate ? toDate(parsed.data.updatedDate) : undefined,
320
+ heroImage: parsed.data.heroImage ? String(parsed.data.heroImage) : undefined,
321
+ },
322
+ body: parsed.content,
323
+ html: await renderMarkdown(parsed.content),
324
+ })
325
+ }
326
+
327
+ return posts.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf())
328
+ }
329
+
330
+ export const getPost = async (s3: S3mini, postId?: string) => {
331
+ const posts = await getPosts(s3)
332
+ return posts.find((post) => post.id === postId)
333
+ }
334
+ ```
335
+
336
+ - [ ] **Step 5: Create `src/lib/gitBug.ts`**
337
+
338
+ Port from `packages/shared/core/src/gitBug.ts`. Replace `R2Bucket` with `S3mini`. Key changes:
339
+
340
+ ```typescript
341
+ import { S3mini } from 's3mini'
342
+ import type { GitBugIssue, GitBugIssueState, GitBugIssuesResult, GitBugComment } from '../types'
343
+
344
+ const safeRepoId = (repoId: string) => {
345
+ if (!/^[a-zA-Z0-9._-]+$/.test(repoId)) throw new Error('Invalid repository id.')
346
+ return repoId
347
+ }
348
+
349
+ const safeIssueId = (issueId: string) => {
350
+ if (!/^[a-f0-9]{16}$/.test(issueId)) throw new Error('Invalid issue id.')
351
+ return issueId
352
+ }
353
+
354
+ const makeIssueId = () => crypto.randomUUID().replaceAll('-', '').slice(0, 16)
355
+ const issuesPrefix = (repoId: string) => `${safeRepoId(repoId)}/issues/`
356
+ const issueKey = (repoId: string, issueId: string) => `${issuesPrefix(repoId)}${safeIssueId(issueId)}.json`
357
+
358
+ const getObjectJson = async <T>(s3: S3mini, key: string): Promise<T | undefined> => {
359
+ try {
360
+ const body = await s3.getObject(key)
361
+ return body ? (JSON.parse(body) as T) : undefined
362
+ } catch { return undefined }
363
+ }
364
+
365
+ const putIssue = async (s3: S3mini, repoId: string, issue: GitBugIssue) => {
366
+ await s3.putObject(issueKey(repoId, issue.id), JSON.stringify(issue, null, 2), 'application/json; charset=utf-8')
367
+ return issue
368
+ }
369
+
370
+ // Replace all `bucket: R2Bucket` with `s3: S3mini` and `bucket.get()` → `s3.getObject()`, `bucket.list()` → `s3.listObjects()`, `bucket.put()` → `s3.putObject()`
371
+ // Remove spamscanner import (not available on Workers — replace with a no-op or skip spam check)
372
+ // Full function signatures change: `async (bucket: R2Bucket, ...)` → `async (s3: S3mini, ...)`
373
+ ```
374
+
375
+ Full file content follows the same patterns as the original `gitBug.ts` but with these replacements:
376
+ - `bucket: R2Bucket` → `s3: S3mini` in all function signatures
377
+ - `bucket.get(key)` → `s3.getObject(key)`
378
+ - `bucket.list({prefix})` → `s3.listObjects('/', prefix)`
379
+ - `bucket.put(key, data, { httpMetadata })` → `s3.putObject(key, data, contentType)`
380
+ - `getObjectJson<T>(bucket, key)` → `getObjectJson<T>(s3, key)`
381
+ - Remove the spamscanner section or guard it with try/catch (it fails on Workers)
382
+
383
+ - [ ] **Step 6: Create `src/lib/gitReader.ts`**
384
+
385
+ This is the most complex port. Port from `packages/shared/core/src/gitReader.ts` (700+ lines). Change all helper functions from `R2Bucket` to `S3mini`:
386
+
387
+ ```typescript
388
+ import { S3mini } from 's3mini'
389
+ import type { FileNode } from '../types'
390
+
391
+ // ... (all existing type definitions and functions unchanged)
392
+
393
+ // Replace the three bucket helpers:
394
+ const readText = async (s3: S3mini, key: string): Promise<string | null> => {
395
+ return await s3.getObject(key)
396
+ }
397
+
398
+ const readBytes = async (s3: S3mini, key: string): Promise<Uint8Array | null> => {
399
+ const buf = await s3.getObjectArrayBuffer(key)
400
+ return buf ? new Uint8Array(buf) : null
401
+ }
402
+
403
+ const readRange = async (s3: S3mini, key: string, offset: number, length: number): Promise<Uint8Array | null> => {
404
+ const end = offset + length - 1
405
+ const response = await s3.getObjectRaw(key, false, offset, end)
406
+ if (!response.ok) return null
407
+ const buf = await response.arrayBuffer()
408
+ return new Uint8Array(buf)
409
+ }
410
+ ```
411
+
412
+ Then replace every `bucket: R2Bucket` parameter with `s3: S3mini` in all internal and exported functions. The rest of the git logic (pack parsing, delta application, tree walking, diff generation) remains identical.
413
+
414
+ Exported functions signature changes:
415
+ - `getCommits(bucket, repoId)` → `getCommits(s3, repoId)`
416
+ - `getFiles(bucket, repoId)` → `getFiles(s3, repoId)` (replace `bucket.list()` with `s3.listObjects()`)
417
+ - `getFileHistory(bucket, repoId, path)` → `getFileHistory(s3, repoId, path)`
418
+ - `getFileContentData(bucket, repoId, path)` → `getFileContentData(s3, repoId, path)`
419
+ - `generateHTMLDiff(bucket, repoId, hash)` → `generateHTMLDiff(s3, repoId, hash)`
420
+
421
+ For `getFiles`, replace pagination loop:
422
+ ```typescript
423
+ // Before:
424
+ const result = await bucket.list({ prefix, cursor })
425
+ for (const obj of result.objects) { ... }
426
+ cursor = result.truncated ? result.cursor : undefined
427
+
428
+ // After:
429
+ const objects = await s3.listObjects('/', prefix)
430
+ if (!objects) return []
431
+ for (const obj of objects) { ... }
432
+ // s3mini auto-paginates — no cursor needed
433
+ ```
434
+
435
+ - [ ] **Step 7: Commit**
436
+
437
+ ```bash
438
+ git add src/lib/ package.json
439
+ git commit -m "feat: port core libraries to S3mini and Hono app structure"
440
+ ```
441
+
442
+ ---
443
+
444
+ ### Task 3: Port UI Components
445
+
446
+ **Files:**
447
+ - Create: `src/components/header.ts`
448
+ - Create: `src/components/footer.ts`
449
+ - Create: `src/components/repo-layout.ts`
450
+ - Create: `src/components/file-layout.ts`
451
+ - Create: `src/components/commit-entry.ts`
452
+ - Create: `src/components/issue-card.ts`
453
+ - Create: `src/components/file-tree.ts`
454
+ - Create: `src/components/formatted-date.ts`
455
+
456
+ **Interfaces:**
457
+ - Consumes: `html` from `hono/html`, `NAV_ITEMS` from `../config`, `RuntimeRepo`, `GitBugIssue`, `FileNode` from `../types`
458
+ - Produces: Component functions returning `HtmlEscapedString | Promise<HtmlEscapedString>` — used by route handlers in `src/index.ts`
459
+
460
+ All components use `hono/html` tagged templates instead of JSX. Pattern for converting JSX → tagged template:
461
+
462
+ ```typescript
463
+ // JSX (before): <div class="foo">{variable}</div>
464
+ // Tagged template (after): html`<div class="foo">${variable}</div>`
465
+ ```
466
+
467
+ Key differences:
468
+ - No `.tsx` extension — files are `.ts`
469
+ - Import `html` from `hono/html`
470
+ - Template literals use `${expr}` instead of `{expr}`
471
+ - Components are functions that return `html` `...` `` results
472
+ - Children are passed as props, not as JSX children
473
+ - `props.children` is included via `${props.children}` in the template
474
+ - Arrays from `.map()` are auto-joined in hono/html templates
475
+ - For raw/untrusted HTML, use the `raw` helper or just inline it (hono/html doesn't double-escape)
476
+
477
+ - [ ] **Step 1: Create `src/components/header.ts`**
478
+
479
+ ```typescript
480
+ import { html } from 'hono/html'
481
+ import { NAV_ITEMS } from '../config'
482
+
483
+ export const Header = (props: { currentPath?: string }) => html`
484
+ <header>
485
+ <div class="wrapper">
486
+ <nav>
487
+ <a href="/" class="logo${props.currentPath === '/' ? ' active' : ''}">
488
+ 木 pyrossh
489
+ </a>
490
+ <div class="links">
491
+ <span id="astro-color-scheme-switch">
492
+ <button id="theme-change" type="button"
493
+ onclick="document.documentElement.toggleAttribute('data-theme')">
494
+ <svg style="display:var(--btn-light)" viewBox="0 0 24 24" width="1.2em" height="1.2em">
495
+ <path fill="currentColor" d="M11.288 4.713Q11 4.425 11 4V2q0-.425.288-.712T12 1t.713.288T13 2v2q0 .425-.288.713T12 5t-.712-.288M16.95 7.05q-.275-.275-.275-.687t.275-.713l1.4-1.425q.3-.3.712-.3t.713.3q.275.275.275.7t-.275.7L18.35 7.05q-.275.275-.7.275t-.7-.275M20 13q-.425 0-.713-.288T19 12t.288-.712T20 11h2q.425 0 .713.288T23 12t-.288.713T22 13zm-8.712 9.713Q11 22.425 11 22v-2q0-.425.288-.712T12 19t.713.288T13 20v2q0 .425-.288.713T12 23t-.712-.288M5.65 7.05l-1.425-1.4q-.3-.3-.3-.725t.3-.7q.275-.275.7-.275t.7.275L7.05 5.65q.275.275.275.7t-.275.7q-.3.275-.7.275t-.7-.275m12.7 12.725l-1.4-1.425q-.275-.3-.275-.712t.275-.688t.688-.275t.712.275l1.425 1.4q.3.275.288.7t-.288.725q-.3.3-.725.3t-.7-.3M2 13q-.425 0-.712-.288T1 12t.288-.712T2 11h2q.425 0 .713.288T5 12t-.288.713T4 13zm2.225 6.775q-.275-.275-.275-.7t.275-.7L5.65 16.95q.275-.275.687-.275t.713.275q.3.3.3.713t-.3.712l-1.4 1.4q-.3.3-.725.3t-.7-.3M7.75 16.25Q6 14.5 6 12t1.75-4.25T12 6t4.25 1.75T18 12t-1.75 4.25T12 18t-4.25-1.75"/>
496
+ </svg>
497
+ <svg style="display:var(--btn-dark)" viewBox="0 0 24 24" width="1.2em" height="1.2em">
498
+ <path fill="currentColor" d="m15 8l-3-3l3-3l3 3zm5 3l-2-2l2-2l2 2zm-7.925 11q-2.1 0-3.937-.8t-3.2-2.162t-2.163-3.2t-.8-3.938q0-3.65 2.325-6.437T10.225 2q-.45 2.475.275 4.838t2.5 4.137t4.138 2.5t4.837.275q-.65 3.6-3.45 5.925T12.075 22"/>
499
+ </svg>
500
+ </button>
501
+ </span>
502
+ <div>|</div>
503
+ ${NAV_ITEMS.map((item) => html`
504
+ <a href="${item.href}" class="${props.currentPath?.startsWith(item.href) ? 'active' : ''}">
505
+ ${item.label}
506
+ </a>
507
+ <div>|</div>
508
+ `)}
509
+ </div>
510
+ </nav>
511
+ </div>
512
+ </header>
513
+ <div class="safe-area" />
514
+ `
515
+ ```
516
+
517
+ - [ ] **Step 2: Create `src/components/footer.ts`**
518
+
519
+ ```typescript
520
+ import { html } from 'hono/html'
521
+
522
+ export const Footer = () => html`
523
+ <footer>
524
+ <div class="wrapper">
525
+ <div class="container">
526
+ <div class="spacer" />
527
+ <p class="copyright-container">
528
+ Copyright © ${new Date().getFullYear()}
529
+ <a class="link" href="https://pyrossh.dev">pyrossh</a>
530
+ </p>
531
+ </div>
532
+ </div>
533
+ </footer>
534
+ <div class="safe-area" />
535
+ `
536
+ ```
537
+
538
+ - [ ] **Step 3: Create `src/components/formatted-date.ts`**
539
+
540
+ ```typescript
541
+ import { html } from 'hono/html'
542
+
543
+ export const FormattedDate = (props: { date: Date }) => html`
544
+ <time datetime="${props.date.toISOString()}">
545
+ ${new Intl.DateTimeFormat('en-GB', { dateStyle: 'long' }).format(new Date(props.date))}
546
+ </time>
547
+ `
548
+ ```
549
+
550
+ - [ ] **Step 4: Create `src/components/commit-entry.ts`**
551
+
552
+ ```typescript
553
+ import { html } from 'hono/html'
554
+
555
+ export const CommitEntry = (props: {
556
+ repoId: string
557
+ commit: { hash: string; author_name?: string; author?: string; date?: string; message: string; body?: string }
558
+ }) => {
559
+ const author = props.commit.author_name || props.commit.author || ''
560
+ return html`
561
+ <div class="commit-entry">
562
+ <div>
563
+ <a href="/repos/${props.repoId}/commits/${props.commit.hash}" title="${props.commit.hash}" rel="nofollow">
564
+ ${props.commit.hash.substring(0, 8)}
565
+ </a>
566
+ —<strong>${author}</strong>
567
+ <small class="pull-right">
568
+ ${props.commit.date ? html`
569
+ <span>${new Date(props.commit.date).toLocaleDateString('en', { year: 'numeric', month: 'short', day: 'numeric' })}</span>
570
+ ` : ''}
571
+ </small>
572
+ </div>
573
+ <pre class="commit">${props.commit.message}</pre>
574
+ </div>`
575
+ }
576
+ ```
577
+
578
+ - [ ] **Step 5: Create `src/components/issue-card.ts`**
579
+
580
+ ```typescript
581
+ import { html } from 'hono/html'
582
+ import type { GitBugIssue } from '../types'
583
+
584
+ export const IssueCard = (props: { repoId: string; issue: GitBugIssue }) => {
585
+ const { issue, repoId } = props
586
+ const date = issue.updatedAt ?? issue.createdAt
587
+ return html`
588
+ <article class="issue-card">
589
+ <div class="issue-title">
590
+ <a href="/repos/${repoId}/issues/${issue.id}" title="${issue.id}">${issue.title}</a>
591
+ <span class="state ${issue.state}">${issue.state}</span>
592
+ </div>
593
+ <div class="meta">
594
+ <a href="/repos/${repoId}/issues/${issue.id}" title="${issue.id}">${issue.id.substring(0, 8)}</a>
595
+ ${issue.author ? html`<span>by ${issue.author}</span>` : ''}
596
+ ${date ? html`<span>${new Date(date).toLocaleDateString('en', { year: 'numeric', month: 'short', day: 'numeric' })}</span>` : ''}
597
+ ${issue.comments.length > 0 ? html`<span>${issue.comments.length} comments</span>` : ''}
598
+ </div>
599
+ ${issue.labels.length > 0 ? html`
600
+ <div class="labels">
601
+ ${issue.labels.map(label => html`<span class="label">#${label}</span>`)}
602
+ </div>
603
+ ` : ''}
604
+ </article>`
605
+ }
606
+ ```
607
+
608
+ - [ ] **Step 6: Create `src/components/file-tree.ts`**
609
+
610
+ ```typescript
611
+ import { html } from 'hono/html'
612
+ import type { FileNode } from '../types'
613
+
614
+ const FileTreeItem = (props: { node: FileNode; repoId: string }) => {
615
+ const { node, repoId } = props
616
+ const href = `/repos/${repoId}/files/${node.path}`
617
+
618
+ if (node.isDirectory && node.children) {
619
+ return html`
620
+ <li>
621
+ <details open="true">
622
+ <summary>
623
+ <a href="${href}" hx-get="${href}" hx-target="#tab-content" hx-push-url="true">${node.name}/</a>
624
+ </summary>
625
+ <ul>
626
+ ${node.children.map(child => FileTreeItem({ node: child, repoId }))}
627
+ </ul>
628
+ </details>
629
+ </li>`
630
+ }
631
+
632
+ return html`
633
+ <li>
634
+ <a href="${href}" hx-get="${href}" hx-target="#tab-content" hx-push-url="true">${node.name}</a>
635
+ </li>`
636
+ }
637
+
638
+ export const FileTree = (props: { nodes: FileNode[]; repoId: string }) => html`
639
+ <div class="file-tree">
640
+ <ul>
641
+ ${props.nodes.map(node => FileTreeItem({ node, repoId: props.repoId }))}
642
+ </ul>
643
+ </div>`
644
+ ```
645
+
646
+ - [ ] **Step 7: Create `src/components/repo-layout.ts`**
647
+
648
+ Wraps children in a repo-specific layout (header, description, tabs). The `Layout` (full HTML document) is applied by the route handler — RepoLayout only provides the repo content section:
649
+
650
+ ```typescript
651
+ import { html } from 'hono/html'
652
+ import type { RuntimeRepo } from '../types'
653
+
654
+ export const RepoLayout = (props: {
655
+ repo: RuntimeRepo
656
+ currentTab: string
657
+ children: any
658
+ }) => {
659
+ const { repo, currentTab, children } = props
660
+ const { title, description, tags } = repo.data
661
+ return html`
662
+ <div class="repo" style="padding:0.5rem;background:var(--color-box-bg);font-size:0.95rem">
663
+ <div class="header" style="display:flex;flex-direction:row;align-items:baseline">
664
+ <h1 style="font-size:1.6rem;font-weight:500;margin-bottom:0.1rem;flex:1;margin:0">
665
+ <a href="/" style="color:var(--color-link)">~repos</a>
666
+ /${title}
667
+ </h1>
668
+ <div class="tags" style="margin-top:0.5rem;display:flex;flex-wrap:wrap;gap:0.25rem">
669
+ ${tags.map(tag => html`<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>`)}
670
+ </div>
671
+ </div>
672
+ <h3 style="font-size:1rem;font-weight:500">
673
+ GIT_CONFIG_PARAMETERS="'http.version=HTTP/1.1'" git clone
674
+ <a style="color:var(--color-link)">https://git.pyrossh.dev/${title}/.git</a> ${title}
675
+ </h3>
676
+ <div style="font-size:0.95rem">${description}</div>
677
+ <hr style="margin-top:0.5rem;border-color:var(--color-text)" />
678
+ <div class="nav" style="display:flex;align-items:center;margin-top:0.5rem">
679
+ ${['Readme', 'Commits', 'Files'].map(tab => {
680
+ const href = tab === 'Readme' ? `/repos/${title}` : `/repos/${title}/${tab.toLowerCase()}`
681
+ const active = currentTab === tab
682
+ return html`
683
+ <div style="padding-right:0.5rem">
684
+ <a href="${href}" aria-current="${active}"
685
+ hx-get="${href}" hx-target="#tab-content" hx-push-url="true"
686
+ style="font-size:1rem;font-weight:500;padding-right:0.5rem;cursor:pointer;color:var(--color-link)${active ? ';text-underline-offset:4px;text-decoration:underline' : ''}">
687
+ ${tab}
688
+ </a>
689
+ <span style="padding-right:0.5rem">|</span>
690
+ </div>`
691
+ })}
692
+ </div>
693
+ </div>
694
+ <div id="tab-content">
695
+ ${children}
696
+ </div>`
697
+ }
698
+ ```
699
+
700
+ - [ ] **Step 8: Create `src/components/file-layout.ts`**
701
+
702
+ ```typescript
703
+ import { html } from 'hono/html'
704
+ import type { RuntimeRepo, FileNode } from '../types'
705
+
706
+ export const FileLayout = (props: {
707
+ repo: RuntimeRepo
708
+ file: FileNode
709
+ pathname: string
710
+ children: any
711
+ }) => {
712
+ const { repo, file, pathname, children } = props
713
+ return html`
714
+ ${children}
715
+
716
+ <div class="file-layout">
717
+ <hr />
718
+ <div class="title"><h3>${file.name}</h3></div>
719
+ <hr />
720
+ <div class="nav">
721
+ <div>
722
+ <a aria-current="${pathname === `/repos/${repo.id}/files/${file.name}`}"
723
+ href="/repos/${repo.id}/files/${file.name}"
724
+ hx-get="/repos/${repo.id}/files/${file.name}"
725
+ hx-target="#tab-content" hx-push-url="true">Contents</a>
726
+ </div>
727
+ <div>|</div>
728
+ <div>
729
+ <a aria-current="${pathname === `/repos/${repo.id}/files/${file.name}/history`}"
730
+ href="/repos/${repo.id}/files/${file.name}/history"
731
+ hx-get="/repos/${repo.id}/files/${file.name}/history"
732
+ hx-target="#tab-content" hx-push-url="true">History</a>
733
+ </div>
734
+ <div>|</div>
735
+ <div>
736
+ <a aria-current="${pathname === `/repos/${repo.id}/files/${file.name}/blame`}"
737
+ href="/repos/${repo.id}/files/${file.name}/blame"
738
+ hx-get="/repos/${repo.id}/files/${file.name}/blame"
739
+ hx-target="#tab-content" hx-push-url="true">Blame</a>
740
+ </div>
741
+ </div>
742
+ <hr />
743
+ </div>`
744
+ }
745
+ ```
746
+
747
+ - [ ] **Step 9: Commit**
748
+
749
+ ```bash
750
+ git add src/components/
751
+ git commit -m "feat: port UI components to hono/html tagged templates"
752
+ ```
753
+
754
+ ---
755
+
756
+ ### Task 4: Create App Entry with All Routes
757
+
758
+ **Files:**
759
+ - Create: `src/index.ts`
760
+
761
+ **Interfaces:**
762
+ - Consumes: All components from `src/components/*`, all lib from `src/lib/*`, `html` from `hono/html`, `css` from `hono/css`, `getS3()`, `config`, `types`
763
+ - Produces: Hono app default export (`ExportedHandler`)
764
+
765
+ - [ ] **Step 1: Create `src/index.ts` with Layout wrapper**
766
+
767
+ ```typescript
768
+ import { Hono } from 'hono'
769
+ import { html } from 'hono/html'
770
+ import { Header } from './components/header'
771
+ import { Footer } from './components/footer'
772
+ import { SITE_TITLE, SITE_DESCRIPTION, SITE_URL } from './config'
773
+
774
+ const app = new Hono()
775
+
776
+ const Layout = (props: {
777
+ title: string
778
+ description?: string
779
+ css?: string
780
+ children: any
781
+ }) => {
782
+ const url = new URL(props.title === SITE_TITLE ? '/' : '', SITE_URL) // simplified
783
+ return html`
784
+ <!DOCTYPE html>
785
+ <html lang="en">
786
+ <head>
787
+ <meta charset="utf-8" />
788
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
789
+ <meta name="theme-color" content="#131618" />
790
+ <link rel="icon" type="image/svg+xml" href="/assets/icons/icon.svg" />
791
+ <link rel="alternate" type="application/rss+xml" title="${props.title}" href="/rss.xml" />
792
+ <link rel="canonical" href="${SITE_URL}/" />
793
+ <link rel="stylesheet" href="/assets/css/shared.css" />
794
+ ${props.css ? html`<link rel="stylesheet" href="${props.css}" />` : ''}
795
+ <title>${props.title}</title>
796
+ <meta name="title" content="${props.title}" />
797
+ <meta name="description" content="${props.description ?? SITE_DESCRIPTION}" />
798
+ <meta property="og:title" content="${props.title}" />
799
+ <meta property="og:description" content="${props.description ?? SITE_DESCRIPTION}" />
800
+ <meta property="og:image" content="/assets/icons/icon.svg" />
801
+ <meta name="twitter:card" content="summary_large_image" />
802
+ <script src="https://unpkg.com/[email protected]"></script>
803
+ </head>
804
+ <body>
805
+ ${Header({ currentPath: '' })}
806
+ <div class="wrapper">
807
+ <main>${props.children}</main>
808
+ </div>
809
+ ${Footer()}
810
+ </body>
811
+ </html>`
812
+ }
813
+
814
+ // ---- Routes start here ----
815
+ // All route handlers defined below
816
+ // Pattern: app.get('/path', (c) => c.html(Layout({ title, css, children: html`...` })))
817
+ ```
818
+
819
+ Note: For routes that need the current URL path (for Header active state, canonical URL), use `new URL(c.req.url)` inside the route handler and pass `currentPath` to Layout.
820
+
821
+ **Route ordering matters:** When using catch-all params like `:path{.+}`, more specific routes must be defined first. Define file routes in this order:
822
+ 1. `/repos/:id/files/:path{.+}/history` (before generic `:path`)
823
+ 2. `/repos/:id/files/:path{.+}/blame` (before generic `:path`)
824
+ 3. `/repos/:id/files/:path{.+}` (generic catch-all last)
825
+ Same for issue detail: `/repos/:id/issues/:issueId` before `/repos/:id/issues` POST handler (though GET vs POST differentiates, be explicit).
826
+
827
+ Content porting hint: to convert JSX from worker files to tagged templates:
828
+ - Open the worker's `src/index.tsx` file
829
+ - Copy the JSX inside `<Layout>...</Layout>` (the children content)
830
+ - Replace `{expr}` with `${expr}`
831
+ - Replace `<Comp prop={val}>` with `${Comp({prop: val})}`
832
+ - Replace `<div>{arr.map(i => <span>{i}</span>)}</div>` with `html`<div>${arr.map(i => html`<span>${i}</span>`)}</div>``
833
+ - For `dangerouslySetInnerHTML={{ __html: htmlContent }}`, just use `${htmlContent}` (interpolate the raw HTML string directly)
834
+
835
+ - [ ] **Step 2: Add simple routes (home, cv, robots, 404, 500, only-bible-app)**
836
+
837
+ ```typescript
838
+ // Home
839
+ app.get('/', (c) => {
840
+ return c.html(Layout({
841
+ title: SITE_TITLE,
842
+ description: 'Tech Lead from Bangalore...',
843
+ css: '/assets/css/workers/home.css',
844
+ children: html`<h1>Hello!</h1>
845
+ <p>Tech lead...</p>
846
+ <!-- Content from packages/workers/home/src/index.tsx — convert JSX to tagged templates -->`
847
+ }))
848
+ })
849
+
850
+ // CV
851
+ app.get('/cv', (c) => c.html(Layout({
852
+ title: 'Curriculum Vitae',
853
+ css: '/assets/css/workers/cv.css',
854
+ children: html`
855
+ <h1 style="display:flex;align-items:flex-start;font-weight:800;font-size:2rem;margin-bottom:1rem">
856
+ Curriculum Vitae
857
+ <a href="/assets/pdfs/resume.pdf" target="_blank" style="margin-left:0.4rem;margin-top:0.8rem;color:var(--color-link)">↗</a>
858
+ </h1>
859
+ <div style="border:1px solid black">
860
+ <img src="/assets/pdfs/resume.svg" alt="Resume" style="width:100%" />
861
+ </div>`
862
+ })))
863
+
864
+ // robots.txt
865
+ app.get('/robots.txt', (c) => c.text('User-agent: *\nAllow: /\n'))
866
+
867
+ // 404
868
+ app.notFound((c) => c.html(Layout({
869
+ title: '404 — Not Found',
870
+ children: html`<h1>404</h1><p>Page not found.</p>`
871
+ }), 404))
872
+
873
+ // 500
874
+ app.onError((err, c) => {
875
+ console.error(err)
876
+ return c.html(Layout({
877
+ title: '500 — Server Error',
878
+ children: html`<h1>500</h1><p>Something went wrong.</p>`
879
+ }), 500)
880
+ })
881
+
882
+ // Only Bible App pages
883
+ app.get('/only-bible-app', (c) => c.html(Layout({
884
+ title: 'Only Bible App',
885
+ css: '/assets/css/workers/only-bible-app.css',
886
+ children: html`<!-- Paste: packages/workers/only-bible-app/index/src/index.tsx → convert JSX to tagged templates -->`
887
+ })))
888
+
889
+ app.get('/only-bible-app/privacy-policy', (c) => c.html(Layout({
890
+ title: 'Privacy Policy | Only Bible App',
891
+ css: '/assets/css/workers/only-bible-app-privacy.css',
892
+ children: html`<!-- Paste: packages/workers/only-bible-app/privacy/src/index.tsx → convert JSX to tagged templates -->`
893
+ })))
894
+
895
+ app.get('/only-bible-app/terms-and-conditions', (c) => c.html(Layout({
896
+ title: 'Terms & Conditions | Only Bible App',
897
+ css: '/assets/css/workers/only-bible-app-terms.css',
898
+ children: html`<!-- Paste: packages/workers/only-bible-app/terms/src/index.tsx → convert JSX to tagged templates -->`
899
+ })))
900
+ ```
901
+
902
+ For each route, copy the JSX content from the corresponding worker file and convert:
903
+ - `{variable}` → `${variable}`
904
+ - `<Component prop={value}>` → `${Component({prop: value})}`
905
+ - `<div>{items.map(i => <span>{i}</span>)}</div>` → `html`<div>${items.map(i => html`<span>${i}</span>`)}</div>``
906
+ - `dangerouslySetInnerHTML={{ __html: htmlContent }}` → just insert the raw HTML: `${htmlContent}` (hono/html doesn't escape interpolated values that are already HtmlEscapedString)
907
+ - For raw string HTML from markdown, use `html` tagged template or the `raw` helper
908
+
909
+ - [ ] **Step 3: Add blog routes (depend on S3)**
910
+
911
+ ```typescript
912
+ import { getS3 } from './s3'
913
+ import { getPosts, getPost } from './lib/content'
914
+
915
+ app.get('/posts', async (c) => {
916
+ const posts = await getPosts(getS3())
917
+ return c.html(Layout({
918
+ title: 'Posts',
919
+ css: '/assets/css/workers/posts-index.css',
920
+ children: html`
921
+ <h1 style="font-weight:800;font-size:2rem">Posts</h1>
922
+ <ul style="display:flex;flex-direction:column;list-style:none;padding:0">
923
+ ${posts.map(post => html`
924
+ <li style="display:grid;grid-template-columns:1fr;gap:0.4rem;margin-top:1.25rem;line-height:1.5rem">
925
+ <div>
926
+ <a href="/posts/${post.id}" style="font-size:1.1rem;color:var(--color-post-link);text-underline-offset:4px">${post.data.title}</a>
927
+ <span style="font-size:13pt;display:block">${post.data.description}</span>
928
+ </div>
929
+ <time style="font-size:1.1rem">${post.data.pubDate.toLocaleDateString()}</time>
930
+ </li>`
931
+ )}
932
+ </ul>`
933
+ }))
934
+ })
935
+
936
+ app.get('/posts/:id', async (c) => {
937
+ const postId = c.req.param('id')
938
+ const post = await getPost(getS3(), postId)
939
+ if (!post) return c.redirect('/404')
940
+ return c.html(Layout({
941
+ title: post.data.title,
942
+ description: post.data.description,
943
+ css: '/assets/css/workers/posts-detail.css',
944
+ children: html`
945
+ <article>
946
+ <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>
947
+ <h2 style="font-size:1.1rem;font-weight:500">${post.data.description}</h2>
948
+ <time style="font-size:1.1rem">${post.data.pubDate.toLocaleDateString()}</time>
949
+ <hr style="margin:0.5rem 0;border-color:var(--color-text)" />
950
+ ${post.html}
951
+ </article>`
952
+ }))
953
+ })
954
+ ```
955
+
956
+ - [ ] **Step 4: Add RSS feed**
957
+
958
+ ```typescript
959
+ import { SITE_TITLE, SITE_DESCRIPTION, SITE_URL } from './config'
960
+
961
+ app.get('/rss.xml', async (c) => {
962
+ const posts = await getPosts(getS3())
963
+ const items = posts.map(post => `
964
+ <item>
965
+ <title><![CDATA[${post.data.title}]]></title>
966
+ <description><![CDATA[${post.data.description}]]></description>
967
+ <link>${SITE_URL}/posts/${post.id}/</link>
968
+ <guid>${SITE_URL}/posts/${post.id}/</guid>
969
+ <pubDate>${post.data.pubDate.toUTCString()}</pubDate>
970
+ </item>
971
+ `).join('\n')
972
+
973
+ return c.text(`<?xml version="1.0" encoding="UTF-8"?>
974
+ <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
975
+ <channel>
976
+ <title>${SITE_TITLE}</title>
977
+ <description>${SITE_DESCRIPTION}</description>
978
+ <link>${SITE_URL}</link>
979
+ <atom:link href="${SITE_URL}/rss.xml" rel="self" type="application/rss+xml"/>
980
+ ${items}
981
+ </channel>
982
+ </rss>`, 200, { 'Content-Type': 'application/rss+xml; charset=utf-8' })
983
+ })
984
+ ```
985
+
986
+ - [ ] **Step 5: Add repo routes**
987
+
988
+ ```typescript
989
+ import { getRepos, getRepo, getRepoReadme, getCommits, getFiles, getFileHistory, getFileContentData, generateHTMLDiff,
990
+ buildFileTree, sortChildren, getGitBugIssues, getGitBugIssue, createGitBugIssue, addGitBugIssueComment, setGitBugIssueState,
991
+ renderMarkdown } from './lib/content'
992
+ // Import from the actual lib files — content.ts exports getPosts/getPost/getRepos/getRepo
993
+ // Import git operations from gitReader.ts
994
+ // Import gitBug operations from gitBug.ts
995
+ // Import RepoLayout, FileLayout, FileTree, IssueCard, CommitEntry from components
996
+
997
+ // Repo README
998
+ app.get('/repos/:id', async (c) => {
999
+ const repoId = c.req.param('id')
1000
+ const repo = getRepo(repoId)
1001
+ if (!repo) return c.redirect('/404')
1002
+ const s3 = getS3()
1003
+ const readmeRaw = await getRepoReadme(s3, repo.id)
1004
+ const readmeHtml = readmeRaw ? await renderMarkdown(readmeRaw) : ''
1005
+ return c.html(Layout({
1006
+ title: `${repoId} — pyrossh`,
1007
+ children: html`
1008
+ ${RepoLayout({ repo, currentTab: 'Readme', children: html`<article>${readmeHtml}</article>` })}`
1009
+ }))
1010
+ })
1011
+
1012
+ // Repo Commits list
1013
+ app.get('/repos/:id/commits', async (c) => {
1014
+ const repoId = c.req.param('id')
1015
+ const repo = getRepo(repoId)
1016
+ if (!repo) return c.redirect('/404')
1017
+ const commits = await getCommits(getS3(), repo.id)
1018
+ return c.html(Layout({
1019
+ title: `${repoId} commits — pyrossh`,
1020
+ children: html`
1021
+ ${RepoLayout({ repo, currentTab: 'Commits',
1022
+ children: html`${commits.map(commit => CommitEntry({ repoId: repo.id, commit }))}` })}`
1023
+ }))
1024
+ })
1025
+
1026
+ // Repo Commit detail
1027
+ app.get('/repos/:id/commits/:hash', async (c) => {
1028
+ const { id: repoId, hash } = c.req.param()
1029
+ const repo = getRepo(repoId)
1030
+ if (!repo) return c.redirect('/404')
1031
+ const s3 = getS3()
1032
+ const commits = await getCommits(s3, repo.id)
1033
+ const commit = commits.find(c => c.hash === hash)
1034
+ if (!commit) return c.redirect(`/repos/${repo.id}/commits`)
1035
+ const diffHtml = await generateHTMLDiff(s3, repo.id, commit.hash)
1036
+ return c.html(Layout({
1037
+ title: `${hash.substring(0, 7)} — ${repoId} — pyrossh`,
1038
+ children: html`
1039
+ ${RepoLayout({ repo, currentTab: 'Commits',
1040
+ children: html`
1041
+ ${CommitEntry({ repoId: repo.id, commit })}
1042
+ <div>${diffHtml}</div>` })}`
1043
+ }))
1044
+ })
1045
+
1046
+ // Repo Files list
1047
+ app.get('/repos/:id/files', async (c) => {
1048
+ const repoId = c.req.param('id')
1049
+ const repo = getRepo(repoId)
1050
+ if (!repo) return c.redirect('/404')
1051
+ const files = await getFiles(getS3(), repo.id)
1052
+ const sortedFiles = sortChildren(buildFileTree(files))
1053
+ return c.html(Layout({
1054
+ title: `${repoId} files — pyrossh`,
1055
+ css: '/assets/css/workers/files-index.css',
1056
+ children: html`
1057
+ ${RepoLayout({ repo, currentTab: 'Files',
1058
+ children: FileTree({ nodes: sortedFiles, repoId: repo.id }) })}`
1059
+ }))
1060
+ })
1061
+
1062
+ // Repo File detail
1063
+ app.get('/repos/:id/files/:path{.+}', async (c) => {
1064
+ const repoId = c.req.param('id')
1065
+ const filePath = c.req.param('path')
1066
+ const repo = getRepo(repoId)
1067
+ if (!repo) return c.redirect('/404')
1068
+ const s3 = getS3()
1069
+ const files = await getFiles(s3, repo.id)
1070
+ const file = files.find(f => f.absolutePath === filePath)
1071
+ if (!file) return c.redirect(`/repos/${repo.id}/files`)
1072
+
1073
+ const BINARY_EXTS = ['apk', 'dex', 'ap_', 'jar', 'fnt']
1074
+ const IMAGE_EXTS = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'icns', 'curve', 'atlas']
1075
+ const LARGE_SIZE = 1024 * 512
1076
+
1077
+ const contentBuffer = await getFileContentData(s3, repo.id, file.absolutePath)
1078
+
1079
+ let content: any
1080
+ if (!contentBuffer) content = html`<p>Unable to load file content.</p>`
1081
+ else if (BINARY_EXTS.includes(file.ext)) content = html`<p>Binary file.</p>`
1082
+ else if (file.size > LARGE_SIZE) content = html`<p>File too large (${file.size} bytes).</p>`
1083
+ else if (IMAGE_EXTS.includes(file.ext)) {
1084
+ const b64 = btoa(String.fromCharCode(...contentBuffer))
1085
+ content = html`<img src="data:image/${file.ext};base64,${b64}" alt="${file.path}" />`
1086
+ } else {
1087
+ const text = new TextDecoder('utf-8').decode(contentBuffer).trim() || 'No content to display.'
1088
+ content = html`<pre><code class="language-${file.ext}">${text}</code></pre>`
1089
+ }
1090
+
1091
+ const url = new URL(c.req.url)
1092
+ return c.html(Layout({
1093
+ title: `${file.name} — ${repoId} — pyrossh`,
1094
+ children: html`
1095
+ ${FileLayout({ repo, file, pathname: url.pathname,
1096
+ children: content })}`
1097
+ }))
1098
+ })
1099
+
1100
+ // File history
1101
+ app.get('/repos/:id/files/:path{.+}/history', async (c) => {
1102
+ const repoId = c.req.param('id')
1103
+ const filePath = c.req.param('path').replace(/\/history$/, '')
1104
+ // ... similar pattern using getFileHistory
1105
+ })
1106
+
1107
+ // File blame
1108
+ app.get('/repos/:id/files/:path{.+}/blame', async (c) => {
1109
+ // ... similar pattern showing "WIP" placeholder
1110
+ })
1111
+ ```
1112
+
1113
+ For the history and blame routes, handle the URL carefully — the `:path` wildcard captures everything including `/history` suffix. Need to strip it.
1114
+
1115
+ - [ ] **Step 6: Add issue routes**
1116
+
1117
+ ```typescript
1118
+ import { getGitBugIssues, getGitBugIssue, createGitBugIssue, addGitBugIssueComment, setGitBugIssueState } from './lib/gitBug'
1119
+
1120
+ // Issues list — GET and POST
1121
+ app.get('/repos/:id/issues', async (c) => {
1122
+ const repoId = c.req.param('id')
1123
+ const repo = getRepo(repoId)
1124
+ if (!repo) return c.redirect('/404')
1125
+ const { issues, isAvailable } = await getGitBugIssues(getS3(), repo.id)
1126
+ const openIssues = issues.filter(i => i.state === 'open')
1127
+ const closedIssues = issues.filter(i => i.state === 'closed')
1128
+ return c.html(Layout({
1129
+ title: `${repoId} issues — pyrossh`,
1130
+ css: '/assets/css/workers/issues-index.css',
1131
+ children: html`
1132
+ ${RepoLayout({ repo, currentTab: 'Issues',
1133
+ children: html`
1134
+ <section class="issues">
1135
+ <div class="summary">
1136
+ <strong>${issues.length}</strong> issues
1137
+ <span>${openIssues.length} open</span>
1138
+ <span>${closedIssues.length} closed</span>
1139
+ </div>
1140
+ <form class="new-issue" hx-post="/repos/${repo.id}/issues" hx-target="#issues-list" hx-swap="innerHTML">
1141
+ <input name="repoId" type="hidden" value="${repo.id}" />
1142
+ <input name="title" placeholder="Title" required />
1143
+ <textarea name="body" placeholder="Description"></textarea>
1144
+ <div class="form-row">
1145
+ <input name="author" placeholder="Author" />
1146
+ <input name="labels" placeholder="labels, comma-separated" />
1147
+ <button type="submit">Create</button>
1148
+ </div>
1149
+ </form>
1150
+ <div id="issues-list">
1151
+ ${issues.length > 0
1152
+ ? issues.map(issue => IssueCard({ repoId: repo.id, issue }))
1153
+ : html`<p class="empty">${isAvailable ? 'No issues found.' : 'Issues unavailable.'}</p>`}
1154
+ </div>
1155
+ </section>` })}`
1156
+ }))
1157
+ })
1158
+
1159
+ // Issues list — POST handler (HTMX)
1160
+ app.post('/repos/:id/issues', async (c) => {
1161
+ const repoId = c.req.param('id')
1162
+ if (!c.req.headers.get('HX-Request')) return c.redirect(c.req.url, 303)
1163
+ const form = await c.req.parseBody<{ title: string; body: string; author: string; labels: string }>()
1164
+ // ... create issue, return HTMX fragment
1165
+ })
1166
+
1167
+ // Issue detail — GET, POST (comment), PUT (state)
1168
+ app.get('/repos/:id/issues/:issueId', async (c) => {
1169
+ // ... render issue detail with comments, state form, comment form
1170
+ })
1171
+
1172
+ app.post('/repos/:id/issues/:issueId', async (c) => {
1173
+ // HTMX: add comment
1174
+ })
1175
+
1176
+ app.put('/repos/:id/issues/:issueId', async (c) => {
1177
+ // HTMX: toggle state
1178
+ })
1179
+ ```
1180
+
1181
+ - [ ] **Step 7: Add export default app**
1182
+
1183
+ ```typescript
1184
+ export default app
1185
+ ```
1186
+
1187
+ - [ ] **Step 8: Commit**
1188
+
1189
+ ```bash
1190
+ git add src/index.ts
1191
+ git commit -m "feat: create Hono app with all routes"
1192
+ ```
1193
+
1194
+ ---
1195
+
1196
+ ### Task 5: Install Dependencies and Cleanup
1197
+
1198
+ - [ ] **Step 1: Install dependencies**
1199
+
1200
+ ```bash
1201
+ bun install
1202
+ ```
1203
+
1204
+ - [ ] **Step 2: Remove old monorepo structure**
1205
+
1206
+ Remove all files and directories that are no longer needed:
1207
+
1208
+ ```bash
1209
+ rm -rf packages/ pnpm-workspace.yaml pnpm-lock.yaml tsconfig.base.ts worker-configuration.d.ts
1210
+ rm -rf .alchemy/ alchemy.run.ts scripts/ refs/ repos/
1211
+ rm -rf rust-embed/ node_modules/
1212
+ ```
1213
+
1214
+ - [ ] **Step 3: Update .gitignore**
1215
+
1216
+ Remove monorepo-specific entries, ensure `node_modules` and `.wrangler` are present.
1217
+
1218
+ - [ ] **Step 4: Update root `package.json` scripts**
1219
+
1220
+ ```json
1221
+ {
1222
+ "scripts": {
1223
+ "dev": "wrangler dev",
1224
+ "deploy": "wrangler deploy",
1225
+ "typecheck": "tsc --noEmit"
1226
+ }
1227
+ }
1228
+ ```
1229
+
1230
+ - [ ] **Step 5: Commit**
1231
+
1232
+ ```bash
1233
+ git add -A
1234
+ git commit -m "refactor: remove monorepo, keep single Hono app"
1235
+ ```
1236
+
1237
+ ---
1238
+
1239
+ ### Task 6: Verify
1240
+
1241
+ - [ ] **Step 1: Run typecheck**
1242
+
1243
+ ```bash
1244
+ bun run typecheck
1245
+ ```
1246
+
1247
+ Expected: No errors (or only pre-existing vscode-icons/spamscanner errors if those deps remain).
1248
+
1249
+ - [ ] **Step 2: Start dev server**
1250
+
1251
+ ```bash
1252
+ bun run dev
1253
+ ```
1254
+
1255
+ Verify the app starts without errors. Visit `http://localhost:8787` and check:
1256
+ - Home page loads with correct layout
1257
+ - CV page loads with resume embed
1258
+ - Posts page loads (content from S3)
1259
+ - Repos pages load
1260
+
1261
+ - [ ] **Step 3: Set up S3 credentials for dev**
1262
+
1263
+ Create `.env` with:
1264
+ ```
1265
+ S3_ACCESS_KEY_ID=...
1266
+ S3_SECRET_ACCESS_KEY=...
1267
+ S3_ENDPOINT=...
1268
+ S3_REGION=auto
1269
+ ```
1270
+
1271
+ Wrangler automatically loads `.env` in dev mode.
1272
+
1273
+ - [ ] **Step 4: Commit**
1274
+
1275
+ ```bash
1276
+ git commit -m "chore: initial working Hono app"
1277
+ ```
1278
+
1279
+