website

#astro#js#html#css

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

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


f1ed0fe — pyrossh 2026-07-08T19:48:31+05:30
feat: port core libraries to S3mini

Generated files not shown: package-lock.json

package.json CHANGED
@@ -8,7 +8,16 @@
8
8
  },
9
9
  "dependencies": {
10
10
  "hono": "^4.7.5",
11
- "s3mini": "^0.9.5"
11
+ "s3mini": "^0.9.5",
12
+ "gray-matter": "^4.0.3",
13
+ "unified": "^11.0.0",
14
+ "remark-parse": "^11.0.0",
15
+ "remark-rehype": "^11.1.2",
16
+ "rehype-stringify": "^10.0.1",
17
+ "rehype-expressive-code": "^0.44.0",
18
+ "diff": "^9.0.0",
19
+ "pretty-bytes": "^7.1.0",
20
+ "diff2html": "^3.4.52"
12
21
  },
13
22
  "devDependencies": {
14
23
  "@cloudflare/workers-types": "^4.20250214.0",
src/lib/content.ts ADDED
@@ -0,0 +1,48 @@
1
+ import matter from 'gray-matter'
2
+ import { S3mini } from 's3mini'
3
+ import { REPOS } from '../config'
4
+ import type { RuntimePost, RuntimeRepo } from '../types'
5
+ import { renderMarkdown } from './markdown'
6
+
7
+ const toDate = (value: unknown) => (value instanceof Date ? value : new Date(String(value)))
8
+ const CONTENT_PREFIX = 'content/'
9
+
10
+ export const getRepos = (): RuntimeRepo[] =>
11
+ REPOS.map((repo) => ({ id: repo.title, data: { ...repo } }))
12
+
13
+ export const getRepo = (repoId?: string): RuntimeRepo | undefined =>
14
+ getRepos().find((repo) => repo.id === repoId)
15
+
16
+ export const getPosts = async (s3: S3mini): Promise<RuntimePost[]> => {
17
+ const objects = await s3.listObjects('/', CONTENT_PREFIX)
18
+ if (!objects) return []
19
+ const posts: RuntimePost[] = []
20
+
21
+ for (const obj of objects) {
22
+ if (!obj.Key.endsWith('.md') && !obj.Key.endsWith('.mdx')) continue
23
+ const source = await s3.getObject(obj.Key)
24
+ if (!source) continue
25
+ const parsed = matter(source)
26
+ const id = obj.Key.replace(CONTENT_PREFIX, '').replace(/\.(md|mdx)$/, '')
27
+
28
+ posts.push({
29
+ id,
30
+ data: {
31
+ title: String(parsed.data.title ?? ''),
32
+ description: String(parsed.data.description ?? ''),
33
+ pubDate: toDate(parsed.data.pubDate),
34
+ updatedDate: parsed.data.updatedDate ? toDate(parsed.data.updatedDate) : undefined,
35
+ heroImage: parsed.data.heroImage ? String(parsed.data.heroImage) : undefined,
36
+ },
37
+ body: parsed.content,
38
+ html: await renderMarkdown(parsed.content),
39
+ })
40
+ }
41
+
42
+ return posts.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf())
43
+ }
44
+
45
+ export const getPost = async (s3: S3mini, postId?: string) => {
46
+ const posts = await getPosts(s3)
47
+ return posts.find((post) => post.id === postId)
48
+ }
src/lib/files.ts ADDED
@@ -0,0 +1,113 @@
1
+ import { extensions as folderExtensions } from "vscode-icons/src/iconsManifest/supportedFolders.ts";
2
+ import { extensions as fileExtensions } from "vscode-icons/src/iconsManifest/supportedExtensions.ts";
3
+
4
+ export interface FileNode {
5
+ name: string;
6
+ path: string;
7
+ size: number;
8
+ ext: string;
9
+ absolutePath: string;
10
+ isDirectory: boolean;
11
+ children?: FileNode[];
12
+ }
13
+
14
+ export const sortAll = (a: FileNode, b: FileNode): number => {
15
+ if (a.isDirectory && !b.isDirectory) return -1;
16
+ if (!a.isDirectory && b.isDirectory) return 1;
17
+ return a.name.localeCompare(b.name);
18
+ };
19
+ export const sortChildren = (nodes: FileNode[]) => {
20
+ for (const node of nodes) {
21
+ if (node.isDirectory && node.children) {
22
+ node.children.sort(sortAll);
23
+ sortChildren(node.children);
24
+ }
25
+ }
26
+ return nodes.sort(sortAll);
27
+ };
28
+
29
+ export const buildFileTree = (files: any[]): FileNode[] => {
30
+ const root: FileNode[] = [];
31
+
32
+ for (const file of files) {
33
+ const parts = file.name.split("/");
34
+ let currentLevel = root;
35
+
36
+ for (let i = 0; i < parts.length; i++) {
37
+ const part = parts[i];
38
+ const isLastPart = i === parts.length - 1;
39
+ const currentPath = parts.slice(0, i + 1).join("/");
40
+
41
+ let existingNode = currentLevel.find((node) => node.name === part);
42
+
43
+ if (!existingNode) {
44
+ const newNode: FileNode = {
45
+ name: part,
46
+ path: currentPath,
47
+ isDirectory: !isLastPart,
48
+ size: file.size,
49
+ ext: file.ext,
50
+ absolutePath: file.absolutePath,
51
+ };
52
+ if (!isLastPart) {
53
+ newNode.children = [];
54
+ }
55
+
56
+ currentLevel.push(newNode);
57
+ existingNode = newNode;
58
+ }
59
+
60
+ if (!isLastPart) {
61
+ currentLevel = existingNode.children!;
62
+ }
63
+ }
64
+ }
65
+
66
+ return root;
67
+ };
68
+
69
+ export const resolveFolderIcon = (foldername: string) => {
70
+ for (const item of folderExtensions.supported) {
71
+ if (item.extensions?.includes(foldername)) {
72
+ return item.icon;
73
+ }
74
+ }
75
+ return folderExtensions.default.folder?.icon;
76
+ };
77
+
78
+ export const resolveFileIcon = (filename: string, fileExt: string) => {
79
+ const baseName = filename.split("/").pop() || filename;
80
+
81
+ for (const item of fileExtensions.supported) {
82
+ if (item.filename && item.extensions?.includes(baseName)) {
83
+ return item.icon;
84
+ }
85
+ }
86
+
87
+ for (const item of fileExtensions.supported) {
88
+ if (item.extensions?.includes(fileExt)) {
89
+ return item.icon;
90
+ }
91
+ }
92
+
93
+ for (const item of fileExtensions.supported) {
94
+ for (const lang of item.languages ?? []) {
95
+ if (lang?.knownExtensions?.includes(fileExt)) {
96
+ const langIds = Array.isArray(lang.ids) ? lang.ids : [lang.ids];
97
+ if (langIds.includes(fileExt)) {
98
+ return item.icon;
99
+ }
100
+ }
101
+ }
102
+ }
103
+
104
+ for (const item of fileExtensions.supported) {
105
+ for (const lang of item.languages ?? []) {
106
+ if (lang?.knownExtensions?.includes(fileExt)) {
107
+ return item.icon;
108
+ }
109
+ }
110
+ }
111
+
112
+ return fileExtensions.default.file?.icon;
113
+ };
src/lib/gitBug.ts ADDED
@@ -0,0 +1,187 @@
1
+ import { S3mini } from 's3mini'
2
+
3
+ export type GitBugIssueState = "open" | "closed";
4
+
5
+ export interface GitBugIssue {
6
+ id: string;
7
+ title: string;
8
+ state: GitBugIssueState;
9
+ author?: string;
10
+ createdAt: string;
11
+ updatedAt: string;
12
+ labels: string[];
13
+ body?: string;
14
+ comments: GitBugComment[];
15
+ }
16
+
17
+ export interface GitBugComment {
18
+ id: string;
19
+ author?: string;
20
+ createdAt: string;
21
+ body: string;
22
+ }
23
+
24
+ export interface GitBugIssuesResult {
25
+ issues: GitBugIssue[];
26
+ isAvailable: boolean;
27
+ }
28
+
29
+ const safeRepoId = (repoId: string) => {
30
+ if (!/^[a-zA-Z0-9._-]+$/.test(repoId)) {
31
+ throw new Error("Invalid repository id.");
32
+ }
33
+ return repoId;
34
+ };
35
+
36
+ const safeIssueId = (issueId: string) => {
37
+ if (!/^[a-f0-9]{16}$/.test(issueId)) {
38
+ throw new Error("Invalid issue id.");
39
+ }
40
+ return issueId;
41
+ };
42
+
43
+ const makeIssueId = () => crypto.randomUUID().replaceAll("-", "").slice(0, 16);
44
+ const issuesPrefix = (repoId: string) => `${safeRepoId(repoId)}/issues/`;
45
+ const issueKey = (repoId: string, issueId: string) =>
46
+ `${issuesPrefix(repoId)}${safeIssueId(issueId)}.json`;
47
+
48
+ const normalizeLabels = (labels?: string[]) =>
49
+ labels?.map((label) => label.trim()).filter(Boolean) ?? [];
50
+
51
+ const assertNotSpam = async () => {};
52
+
53
+ const getObjectJson = async <T>(s3: S3mini, key: string) => {
54
+ try {
55
+ const body = await s3.getObject(key);
56
+ return body ? (JSON.parse(body) as T) : undefined;
57
+ } catch (error) {
58
+ if (error && typeof error === "object" && "name" in error && error.name === "NoSuchKey") {
59
+ return undefined;
60
+ }
61
+ throw error;
62
+ }
63
+ };
64
+
65
+ const putIssue = async (s3: S3mini, repoId: string, issue: GitBugIssue) => {
66
+ await s3.putObject(issueKey(repoId, issue.id), JSON.stringify(issue, null, 2), "application/json; charset=utf-8");
67
+ return issue;
68
+ };
69
+
70
+ export const getGitBugIssues = async (
71
+ s3: S3mini,
72
+ repoId: string,
73
+ ): Promise<GitBugIssuesResult> => {
74
+ try {
75
+ const objects = await s3.listObjects('/', issuesPrefix(repoId));
76
+ if (!objects) return { issues: [], isAvailable: true };
77
+
78
+ const issues = (
79
+ await Promise.all(
80
+ objects
81
+ .filter((object) => object.Key.endsWith(".json"))
82
+ .map(async (object) => {
83
+ return await getObjectJson<GitBugIssue>(s3, object.Key);
84
+ }),
85
+ )
86
+ ).filter((issue): issue is GitBugIssue => Boolean(issue));
87
+
88
+ return {
89
+ issues: issues.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)),
90
+ isAvailable: true,
91
+ };
92
+ } catch {
93
+ return {
94
+ issues: [],
95
+ isAvailable: false,
96
+ };
97
+ }
98
+ };
99
+
100
+ export const getGitBugIssue = async (
101
+ s3: S3mini,
102
+ repoId: string,
103
+ issueId: string,
104
+ ): Promise<GitBugIssue | undefined> => {
105
+ return await getObjectJson<GitBugIssue>(s3, issueKey(repoId, issueId));
106
+ };
107
+
108
+ export const createGitBugIssue = async (
109
+ s3: S3mini,
110
+ repoId: string,
111
+ input: {
112
+ title: string;
113
+ body?: string;
114
+ author?: string;
115
+ labels?: string[];
116
+ },
117
+ ) => {
118
+ const labels = normalizeLabels(input.labels);
119
+ await assertNotSpam();
120
+
121
+ const now = new Date().toISOString();
122
+ const issue: GitBugIssue = {
123
+ id: makeIssueId(),
124
+ title: input.title.trim(),
125
+ state: "open",
126
+ author: input.author?.trim() || "anonymous",
127
+ createdAt: now,
128
+ updatedAt: now,
129
+ labels,
130
+ body: input.body?.trim(),
131
+ comments: [],
132
+ };
133
+
134
+ return await putIssue(s3, repoId, issue);
135
+ };
136
+
137
+ export const addGitBugIssueComment = async (
138
+ s3: S3mini,
139
+ repoId: string,
140
+ issueId: string,
141
+ input: {
142
+ body: string;
143
+ author?: string;
144
+ },
145
+ ) => {
146
+ const issue = await getGitBugIssue(s3, repoId, issueId);
147
+ if (!issue) {
148
+ throw new Error("Issue not found.");
149
+ }
150
+
151
+ await assertNotSpam();
152
+
153
+ const now = new Date().toISOString();
154
+ return await putIssue(s3, repoId, {
155
+ ...issue,
156
+ updatedAt: now,
157
+ comments: [
158
+ ...issue.comments,
159
+ {
160
+ id: makeIssueId(),
161
+ author: input.author?.trim() || "anonymous",
162
+ createdAt: now,
163
+ body: input.body.trim(),
164
+ },
165
+ ],
166
+ });
167
+ };
168
+
169
+ export const setGitBugIssueState = async (
170
+ s3: S3mini,
171
+ repoId: string,
172
+ issueId: string,
173
+ state: GitBugIssueState,
174
+ author?: string,
175
+ ) => {
176
+ const issue = await getGitBugIssue(s3, repoId, issueId);
177
+ if (!issue) {
178
+ throw new Error("Issue not found.");
179
+ }
180
+
181
+ return await putIssue(s3, repoId, {
182
+ ...issue,
183
+ author: author?.trim() || issue.author,
184
+ state,
185
+ updatedAt: new Date().toISOString(),
186
+ });
187
+ };
src/lib/gitReader.ts ADDED
@@ -0,0 +1,733 @@
1
+ import { S3mini } from 's3mini'
2
+ import type { FileNode } from "./files";
3
+
4
+ export interface Commit {
5
+ hash: string;
6
+ message: string;
7
+ body: string;
8
+ author_name: string;
9
+ author_email: string;
10
+ date: string;
11
+ branches: string[];
12
+ tags: string[];
13
+ }
14
+
15
+ interface TreeEntry {
16
+ mode: string;
17
+ name: string;
18
+ oid: string;
19
+ }
20
+
21
+ interface ParsedIdx {
22
+ fanout: Uint32Array;
23
+ names: Uint8Array;
24
+ offsets: Uint32Array;
25
+ resolvedOffsets: Float64Array;
26
+ packKey: string;
27
+ packSize?: number;
28
+ nextOffsetByIndex: Float64Array;
29
+ offsetToNextOffset: Map<number, number>;
30
+ }
31
+
32
+ const hexToBytes = (hex: string): Uint8Array => {
33
+ const bytes = new Uint8Array(20);
34
+ for (let i = 0; i < 20; i++) {
35
+ bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
36
+ }
37
+ return bytes;
38
+ };
39
+
40
+ const bytesToHex = (bytes: Uint8Array): string => {
41
+ return Array.from(bytes)
42
+ .map((b) => b.toString(16).padStart(2, "0"))
43
+ .join("");
44
+ };
45
+
46
+ const compareBytes = (a: Uint8Array, b: Uint8Array): number => {
47
+ for (let i = 0; i < a.length && i < b.length; i++) {
48
+ if (a[i] !== b[i]) return a[i] - b[i];
49
+ }
50
+ return a.length - b.length;
51
+ };
52
+
53
+ const inflate = async (data: Uint8Array): Promise<Uint8Array> => {
54
+ const ds = new DecompressionStream("deflate");
55
+ const writer = ds.writable.getWriter();
56
+ writer.write(data);
57
+ writer.close();
58
+ const reader = ds.readable.getReader();
59
+ const chunks: Uint8Array[] = [];
60
+ while (true) {
61
+ const { done, value } = await reader.read();
62
+ if (done) break;
63
+ if (value) chunks.push(value);
64
+ }
65
+ const total = chunks.reduce((a, c) => a + c.length, 0);
66
+ const result = new Uint8Array(total);
67
+ let offset = 0;
68
+ for (const chunk of chunks) {
69
+ result.set(chunk, offset);
70
+ offset += chunk.length;
71
+ }
72
+ return result;
73
+ };
74
+
75
+ const readText = async (s3: S3mini, key: string): Promise<string | null> => {
76
+ return await s3.getObject(key)
77
+ }
78
+
79
+ const readBytes = async (s3: S3mini, key: string): Promise<Uint8Array | null> => {
80
+ const buf = await s3.getObjectArrayBuffer(key)
81
+ return buf ? new Uint8Array(buf) : null
82
+ }
83
+
84
+ const readRange = async (s3: S3mini, key: string, offset: number, length: number): Promise<Uint8Array | null> => {
85
+ const end = offset + length - 1
86
+ const response = await s3.getObjectRaw(key, false, offset, end)
87
+ if (!response.ok) return null
88
+ const buf = await response.arrayBuffer()
89
+ return new Uint8Array(buf)
90
+ }
91
+
92
+ const resolvePackedRef = async (
93
+ s3: S3mini,
94
+ repoId: string,
95
+ refPath: string,
96
+ ): Promise<string | undefined> => {
97
+ const packed = await readText(s3, `${repoId}/.git/packed-refs`);
98
+ if (packed === null) return undefined;
99
+ for (const line of packed.split("\n")) {
100
+ if (line.startsWith("#") || line.startsWith("^")) continue;
101
+ const space = line.indexOf(" ");
102
+ if (space === -1) continue;
103
+ if (line.slice(space + 1).trim() === refPath) {
104
+ return line.slice(0, space).trim() || undefined;
105
+ }
106
+ }
107
+ return undefined;
108
+ };
109
+
110
+ const resolveRef = async (s3: S3mini, repoId: string): Promise<string | undefined> => {
111
+ const head = await readText(s3, `${repoId}/.git/HEAD`);
112
+ if (head === null) return undefined;
113
+ const trimmed = head.trim();
114
+ if (trimmed.startsWith("ref: ")) {
115
+ const refPath = trimmed.slice(5);
116
+ const oid = await readText(s3, `${repoId}/.git/${refPath}`);
117
+ if (oid) return oid.trim() || undefined;
118
+ return await resolvePackedRef(s3, repoId, refPath);
119
+ }
120
+ return trimmed || undefined;
121
+ };
122
+
123
+ const parseIdx = (data: Uint8Array, packKey: string): ParsedIdx => {
124
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
125
+ const fanout = new Uint32Array(256);
126
+ for (let i = 0; i < 256; i++) {
127
+ fanout[i] = view.getUint32(8 + i * 4);
128
+ }
129
+ const count = fanout[255];
130
+ const namesOffset = 8 + 256 * 4;
131
+ const names = data.slice(namesOffset, namesOffset + count * 20);
132
+ const crcOffset = namesOffset + count * 20;
133
+ const offsetOffset = crcOffset + count * 4;
134
+ const offsets = new Uint32Array(count);
135
+ const resolvedOffsets = new Float64Array(count);
136
+ let needsLarge = false;
137
+ for (let i = 0; i < count; i++) {
138
+ offsets[i] = view.getUint32(offsetOffset + i * 4);
139
+ if (offsets[i] & 0x80000000) needsLarge = true;
140
+ }
141
+ if (needsLarge) {
142
+ const largeOffsetOffset = offsetOffset + count * 4;
143
+ let largeIdx = 0;
144
+ for (let i = 0; i < count; i++) {
145
+ if (offsets[i] & 0x80000000) {
146
+ const v = view.getBigUint64(largeOffsetOffset + largeIdx * 8);
147
+ resolvedOffsets[i] = Number(v);
148
+ largeIdx++;
149
+ } else {
150
+ resolvedOffsets[i] = offsets[i];
151
+ }
152
+ }
153
+ } else {
154
+ for (let i = 0; i < count; i++) {
155
+ resolvedOffsets[i] = offsets[i];
156
+ }
157
+ }
158
+
159
+ const sortedIndices = new Uint32Array(count);
160
+ for (let i = 0; i < count; i++) sortedIndices[i] = i;
161
+ sortedIndices.sort((a, b) => resolvedOffsets[a] - resolvedOffsets[b]);
162
+
163
+ const nextOffsetByIndex = new Float64Array(count);
164
+ const offsetToNextOffset = new Map<number, number>();
165
+ for (let si = 0; si < count; si++) {
166
+ const idx = sortedIndices[si];
167
+ const offset = resolvedOffsets[idx];
168
+ const nextSi = si + 1;
169
+ if (nextSi < count) {
170
+ const nextOffset = resolvedOffsets[sortedIndices[nextSi]];
171
+ nextOffsetByIndex[idx] = nextOffset;
172
+ offsetToNextOffset.set(offset, nextOffset);
173
+ } else {
174
+ nextOffsetByIndex[idx] = Infinity;
175
+ offsetToNextOffset.set(offset, Infinity);
176
+ }
177
+ }
178
+
179
+ return {
180
+ fanout,
181
+ names,
182
+ offsets,
183
+ resolvedOffsets,
184
+ packKey,
185
+ nextOffsetByIndex,
186
+ offsetToNextOffset,
187
+ };
188
+ };
189
+
190
+ const findOidInIdx = (idx: ParsedIdx, oidHex: string): number | undefined => {
191
+ const oidBytes = hexToBytes(oidHex);
192
+ const firstByte = oidBytes[0];
193
+ const lo = firstByte > 0 ? idx.fanout[firstByte - 1] : 0;
194
+ const hi = idx.fanout[firstByte];
195
+ let left = lo;
196
+ let right = hi;
197
+ while (left < right) {
198
+ const mid = Math.floor((left + right) / 2);
199
+ const midOid = idx.names.slice(mid * 20, mid * 20 + 20);
200
+ const cmp = compareBytes(midOid, oidBytes);
201
+ if (cmp < 0) left = mid + 1;
202
+ else if (cmp > 0) right = mid;
203
+ else return mid;
204
+ }
205
+ return undefined;
206
+ };
207
+
208
+ const getOffsetInIdx = (idx: ParsedIdx, index: number): number => {
209
+ return idx.resolvedOffsets[index];
210
+ };
211
+
212
+ const parseVarint = (data: Uint8Array, offset: number): { value: number; size: number } => {
213
+ let value = 0;
214
+ let shift = 0;
215
+ let i = offset;
216
+ while (i < data.length) {
217
+ const byte = data[i];
218
+ value |= (byte & 0x7f) << shift;
219
+ shift += 7;
220
+ i++;
221
+ if (!(byte & 0x80)) break;
222
+ }
223
+ return { value, size: i - offset };
224
+ };
225
+
226
+ const GIT_OBJECT_TYPES = ["", "commit", "tree", "blob", "tag"];
227
+
228
+ let idxCache: Map<string, ParsedIdx> | undefined;
229
+ let packIdxKeysCache: Map<string, string[]> | undefined;
230
+
231
+ const getPackIdxKeys = async (s3: S3mini, repoId: string): Promise<string[]> => {
232
+ if (!packIdxKeysCache) packIdxKeysCache = new Map();
233
+ let keys = packIdxKeysCache.get(repoId);
234
+ if (keys) return keys;
235
+ const packPrefix = `${repoId}/.git/objects/pack/`;
236
+ const objects = await s3.listObjects('/', packPrefix);
237
+ keys = objects ? objects.map((o) => o.Key).filter((k) => k.endsWith(".idx")) : [];
238
+ packIdxKeysCache.set(repoId, keys);
239
+ return keys;
240
+ };
241
+
242
+ const getParsedIdx = async (s3: S3mini, idxKey: string): Promise<ParsedIdx | undefined> => {
243
+ if (!idxCache) idxCache = new Map();
244
+ let idx = idxCache.get(idxKey);
245
+ if (idx) return idx;
246
+ const data = await readBytes(s3, idxKey);
247
+ if (data === null) return undefined;
248
+ const packKey = idxKey.replace(/\.idx$/, ".pack");
249
+ idx = parseIdx(data, packKey);
250
+ idxCache.set(idxKey, idx);
251
+ return idx;
252
+ };
253
+
254
+ const readRawObject = async (
255
+ s3: S3mini,
256
+ repoId: string,
257
+ oid: string,
258
+ ): Promise<{ type: string; payload: Uint8Array } | undefined> => {
259
+ const idxKeys = await getPackIdxKeys(s3, repoId);
260
+
261
+ for (const idxKey of idxKeys) {
262
+ const idx = await getParsedIdx(s3, idxKey);
263
+ if (!idx) continue;
264
+ const objectIndex = findOidInIdx(idx, oid);
265
+ if (objectIndex === undefined) continue;
266
+
267
+ const offset = getOffsetInIdx(idx, objectIndex);
268
+ return await readPackedObject(
269
+ s3,
270
+ repoId,
271
+ idx.packKey,
272
+ offset,
273
+ new Set(),
274
+ idx.nextOffsetByIndex[objectIndex],
275
+ idx.offsetToNextOffset,
276
+ );
277
+ }
278
+ return undefined;
279
+ };
280
+
281
+ const readPackedObject = async (
282
+ s3: S3mini,
283
+ repoId: string,
284
+ packKey: string,
285
+ offset: number,
286
+ visited: Set<string>,
287
+ nextOffset?: number,
288
+ offsetToNextOffset?: Map<number, number>,
289
+ ): Promise<{ type: string; payload: Uint8Array } | undefined> => {
290
+ const maxRead =
291
+ nextOffset && nextOffset !== Infinity
292
+ ? Math.min(nextOffset - offset, 5 * 1024 * 1024)
293
+ : 5 * 1024 * 1024;
294
+ const headerBuf = await readRange(s3, packKey, offset, Math.min(128, maxRead));
295
+ if (headerBuf === null) return undefined;
296
+
297
+ const { value: typeSize, size: hdrSize } = parseVarint(headerBuf, 0);
298
+ const type = (typeSize >> 4) & 0x07;
299
+
300
+ if (type === 6) {
301
+ let i = hdrSize;
302
+ let byte = headerBuf[i++];
303
+ let negOffset = byte & 0x7f;
304
+ while (byte & 0x80) {
305
+ negOffset += 1;
306
+ byte = headerBuf[i++];
307
+ negOffset = (negOffset << 7) + (byte & 0x7f);
308
+ }
309
+ const ofsSize = i - hdrSize;
310
+ const baseOffset = offset - negOffset;
311
+ const objStart = offset + hdrSize + ofsSize;
312
+ const dataLen = nextOffset
313
+ ? Math.min(nextOffset - objStart, maxRead - hdrSize - ofsSize)
314
+ : maxRead;
315
+ const compressed = await readRange(s3, packKey, objStart, dataLen);
316
+ if (compressed === null) return undefined;
317
+ const inflated = await inflate(compressed);
318
+ const baseNextOffset = offsetToNextOffset?.get(baseOffset);
319
+ const base = await readPackedObject(
320
+ s3,
321
+ repoId,
322
+ packKey,
323
+ baseOffset,
324
+ visited,
325
+ baseNextOffset,
326
+ offsetToNextOffset,
327
+ );
328
+ if (!base) return undefined;
329
+ return { type: base.type, payload: applyDelta(inflated, base.payload) };
330
+ }
331
+
332
+ if (type === 7) {
333
+ const baseOid = bytesToHex(headerBuf.slice(hdrSize, hdrSize + 20));
334
+ if (visited.has(baseOid)) return undefined;
335
+ visited.add(baseOid);
336
+ const objStart = offset + hdrSize + 20;
337
+ const dataLen = nextOffset ? Math.min(nextOffset - objStart, maxRead - hdrSize - 20) : maxRead;
338
+ const compressed = await readRange(s3, packKey, objStart, dataLen);
339
+ if (compressed === null) return undefined;
340
+ const inflated = await inflate(compressed);
341
+ const base = await readRawObject(s3, repoId, baseOid);
342
+ if (!base) return undefined;
343
+ return { type: base.type, payload: applyDelta(inflated, base.payload) };
344
+ }
345
+
346
+ const objStart = offset + hdrSize;
347
+ const dataLen = nextOffset ? nextOffset - objStart : maxRead;
348
+ const compressed = await readRange(s3, packKey, objStart, dataLen);
349
+ if (compressed === null) return undefined;
350
+ const payload = await inflate(compressed);
351
+ const typeName = GIT_OBJECT_TYPES[type] || "unknown";
352
+ return { type: typeName, payload };
353
+ };
354
+
355
+ const applyDelta = (delta: Uint8Array, base: Uint8Array): Uint8Array => {
356
+ let i = 0;
357
+ const { value: baseSize, size: bs } = parseVarint(delta, i);
358
+ i += bs;
359
+ const { value: resultSize, size: rs } = parseVarint(delta, i);
360
+ i += rs;
361
+
362
+ const result = new Uint8Array(resultSize);
363
+ let outPos = 0;
364
+
365
+ while (i < delta.length) {
366
+ const cmd = delta[i];
367
+ i++;
368
+ if (cmd & 0x80) {
369
+ let copyOffset = 0;
370
+ let copySize = 0;
371
+ if (cmd & 0x01) copyOffset = delta[i++];
372
+ if (cmd & 0x02) copyOffset |= delta[i++] << 8;
373
+ if (cmd & 0x04) copyOffset |= delta[i++] << 16;
374
+ if (cmd & 0x08) copyOffset |= delta[i++] << 24;
375
+ if (cmd & 0x10) copySize = delta[i++];
376
+ if (cmd & 0x20) copySize |= delta[i++] << 8;
377
+ if (cmd & 0x40) copySize |= delta[i++] << 16;
378
+ if (copySize === 0) copySize = 0x10000;
379
+ for (let j = 0; j < copySize; j++) {
380
+ result[outPos + j] = base[copyOffset + j];
381
+ }
382
+ outPos += copySize;
383
+ } else {
384
+ for (let j = 0; j < cmd; j++) {
385
+ result[outPos + j] = delta[i + j];
386
+ }
387
+ i += cmd;
388
+ outPos += cmd;
389
+ }
390
+ }
391
+
392
+ return result;
393
+ };
394
+
395
+ const readGitObject = async (
396
+ s3: S3mini,
397
+ repoId: string,
398
+ oid: string,
399
+ ): Promise<{ type: string; payload: Uint8Array } | undefined> => {
400
+ return await readRawObject(s3, repoId, oid);
401
+ };
402
+
403
+ const parseCommit = (
404
+ payload: Uint8Array,
405
+ ): {
406
+ tree: string;
407
+ parents: string[];
408
+ message: string;
409
+ author?: { name: string; email: string; when: string };
410
+ } => {
411
+ const text = new TextDecoder().decode(payload);
412
+ const lines = text.split("\n");
413
+ let i = 0;
414
+ let tree = "";
415
+ const parents: string[] = [];
416
+ let author: { name: string; email: string; when: string } | undefined;
417
+
418
+ while (i < lines.length && lines[i] !== "") {
419
+ const line = lines[i];
420
+ if (line.startsWith("tree ")) tree = line.slice(5);
421
+ else if (line.startsWith("parent ")) parents.push(line.slice(7));
422
+ else if (line.startsWith("author ")) {
423
+ const match = line.slice(7).match(/^(.*?) <(.*?)> (\d+)/);
424
+ if (match) author = { name: match[1], email: match[2], when: match[3] };
425
+ }
426
+ i++;
427
+ }
428
+
429
+ const message = lines
430
+ .slice(i + 1)
431
+ .join("\n")
432
+ .trim();
433
+ return { tree, parents, message, author };
434
+ };
435
+
436
+ const parseTree = (payload: Uint8Array): TreeEntry[] => {
437
+ const entries: TreeEntry[] = [];
438
+ let offset = 0;
439
+ while (offset < payload.length) {
440
+ const nullByte = payload.indexOf(0, offset);
441
+ if (nullByte === -1) break;
442
+ const header = new TextDecoder().decode(payload.slice(offset, nullByte));
443
+ const space = header.indexOf(" ");
444
+ if (space === -1) break;
445
+ const mode = header.slice(0, space);
446
+ const name = header.slice(space + 1);
447
+ offset = nullByte + 1;
448
+ const oidBytes = payload.slice(offset, offset + 20);
449
+ if (oidBytes.length < 20) break;
450
+ const oid = bytesToHex(oidBytes);
451
+ entries.push({ mode, name, oid });
452
+ offset += 20;
453
+ }
454
+ return entries;
455
+ };
456
+
457
+ const buildCommit = (oid: string, parsed: ReturnType<typeof parseCommit>): Commit => ({
458
+ hash: oid,
459
+ message: parsed.message.split("\n")[0],
460
+ body: parsed.message,
461
+ author_name: parsed.author?.name ?? "",
462
+ author_email: parsed.author?.email ?? "",
463
+ date: parsed.author?.when ? new Date(parseInt(parsed.author.when) * 1000).toISOString() : "",
464
+ branches: [],
465
+ tags: [],
466
+ });
467
+
468
+ export const getCommits = async (s3: S3mini, repoId: string): Promise<Commit[]> => {
469
+ const cached = await s3.getObject(`${repoId}/.git/commits.json`);
470
+ if (cached) {
471
+ return JSON.parse(cached) as Commit[];
472
+ }
473
+
474
+ const headOid = await resolveRef(s3, repoId);
475
+ if (!headOid) return [];
476
+
477
+ const commits: Commit[] = [];
478
+ let oid: string | undefined = headOid;
479
+
480
+ while (oid) {
481
+ const obj = await readGitObject(s3, repoId, oid);
482
+ if (!obj || obj.type !== "commit") break;
483
+ const parsed = parseCommit(obj.payload);
484
+ commits.push(buildCommit(oid, parsed));
485
+ oid = parsed.parents[0];
486
+ }
487
+
488
+ return commits;
489
+ };
490
+
491
+ export const getFiles = async (s3: S3mini, repoId: string): Promise<FileNode[]> => {
492
+ const cached = await s3.getObject(`${repoId}/.git/files.json`);
493
+ if (cached) {
494
+ return JSON.parse(cached) as FileNode[];
495
+ }
496
+
497
+ const prefix = `${repoId}/`;
498
+ const files: FileNode[] = [];
499
+
500
+ const objects = await s3.listObjects('/', prefix);
501
+ if (!objects) return [];
502
+
503
+ for (const obj of objects) {
504
+ const key = obj.Key;
505
+ if (key.startsWith(`${prefix}.git`)) continue;
506
+ if (key.endsWith("/")) continue;
507
+
508
+ const relativePath = key.slice(prefix.length);
509
+ if (!relativePath) continue;
510
+
511
+ const extIndex = relativePath.lastIndexOf(".");
512
+ const ext = extIndex >= 0 ? relativePath.slice(extIndex + 1) : "";
513
+
514
+ files.push({
515
+ name: relativePath,
516
+ path: relativePath,
517
+ ext,
518
+ size: obj.Size,
519
+ isDirectory: false,
520
+ absolutePath: relativePath,
521
+ });
522
+ }
523
+
524
+ return files;
525
+ };
526
+
527
+ const fileOidInTree = async (
528
+ s3: S3mini,
529
+ repoId: string,
530
+ treeOid: string,
531
+ filePath: string,
532
+ ): Promise<string | undefined> => {
533
+ const parts = filePath.split("/");
534
+ let currentTree = treeOid;
535
+ for (const part of parts) {
536
+ const obj = await readGitObject(s3, repoId, currentTree);
537
+ if (!obj || obj.type !== "tree") return undefined;
538
+ const entries = parseTree(obj.payload);
539
+ const entry = entries.find((e) => e.name === part);
540
+ if (!entry) return undefined;
541
+ if (part === parts[parts.length - 1]) return entry.oid;
542
+ if (entry.mode !== "40000") return undefined;
543
+ currentTree = entry.oid;
544
+ }
545
+ return undefined;
546
+ };
547
+
548
+ export const getFileHistory = async (
549
+ s3: S3mini,
550
+ repoId: string,
551
+ filePath: string,
552
+ ): Promise<Commit[]> => {
553
+ const headOid = await resolveRef(s3, repoId);
554
+ if (!headOid) return [];
555
+
556
+ const commits: Commit[] = [];
557
+ let oid: string | undefined = headOid;
558
+
559
+ while (oid) {
560
+ const commitObj = await readGitObject(s3, repoId, oid);
561
+ if (!commitObj || commitObj.type !== "commit") break;
562
+ const parsed = parseCommit(commitObj.payload);
563
+
564
+ const fileOid = await fileOidInTree(s3, repoId, parsed.tree, filePath);
565
+ let parentFileOid: string | undefined;
566
+ if (parsed.parents[0]) {
567
+ const parentObj = await readGitObject(s3, repoId, parsed.parents[0]);
568
+ if (parentObj && parentObj.type === "commit") {
569
+ const parentParsed = parseCommit(parentObj.payload);
570
+ parentFileOid = await fileOidInTree(s3, repoId, parentParsed.tree, filePath);
571
+ }
572
+ }
573
+
574
+ if (fileOid !== parentFileOid) {
575
+ commits.push(buildCommit(oid, parsed));
576
+ }
577
+
578
+ oid = parsed.parents[0];
579
+ }
580
+
581
+ return commits;
582
+ };
583
+
584
+ export const getFileContentData = async (
585
+ s3: S3mini,
586
+ repoId: string,
587
+ filePath: string,
588
+ ): Promise<Uint8Array | undefined> => {
589
+ const buf = await s3.getObjectArrayBuffer(`${repoId}/${filePath}`);
590
+ return buf ? new Uint8Array(buf) : undefined;
591
+ };
592
+
593
+ interface Change {
594
+ filepath: string;
595
+ changeType: "added" | "deleted" | "modified";
596
+ newOid?: string;
597
+ oldOid?: string;
598
+ }
599
+
600
+ const getTreeEntries = async (
601
+ s3: S3mini,
602
+ repoId: string,
603
+ treeOid: string,
604
+ ): Promise<Map<string, { mode: string; oid: string }>> => {
605
+ const map = new Map<string, { mode: string; oid: string }>();
606
+ const obj = await readGitObject(s3, repoId, treeOid);
607
+ if (!obj || obj.type !== "tree") return map;
608
+ const entries = parseTree(obj.payload);
609
+ for (const entry of entries) {
610
+ map.set(entry.name, { mode: entry.mode, oid: entry.oid });
611
+ }
612
+ return map;
613
+ };
614
+
615
+ const diffTrees = async (
616
+ s3: S3mini,
617
+ repoId: string,
618
+ oldTreeOid: string | undefined,
619
+ newTreeOid: string,
620
+ prefix = "",
621
+ ): Promise<Change[]> => {
622
+ const changes: Change[] = [];
623
+ const oldEntries = oldTreeOid ? await getTreeEntries(s3, repoId, oldTreeOid) : new Map();
624
+ const newEntries = await getTreeEntries(s3, repoId, newTreeOid);
625
+
626
+ const allNames = new Set([...oldEntries.keys(), ...newEntries.keys()]);
627
+
628
+ for (const name of allNames) {
629
+ const path = prefix ? `${prefix}/${name}` : name;
630
+ const oldEntry = oldEntries.get(name);
631
+ const newEntry = newEntries.get(name);
632
+
633
+ if (!newEntry) {
634
+ if (oldEntry && oldEntry.mode !== "40000") {
635
+ changes.push({ filepath: path, changeType: "deleted", oldOid: oldEntry.oid });
636
+ }
637
+ } else if (!oldEntry) {
638
+ if (newEntry.mode !== "40000") {
639
+ changes.push({ filepath: path, changeType: "added", newOid: newEntry.oid });
640
+ } else {
641
+ changes.push(...(await diffTrees(s3, repoId, undefined, newEntry.oid, path)));
642
+ }
643
+ } else if (oldEntry.mode === "40000" && newEntry.mode === "40000") {
644
+ changes.push(...(await diffTrees(s3, repoId, oldEntry.oid, newEntry.oid, path)));
645
+ } else if (oldEntry.oid !== newEntry.oid) {
646
+ if (newEntry.mode !== "40000") {
647
+ changes.push({
648
+ filepath: path,
649
+ changeType: "modified",
650
+ newOid: newEntry.oid,
651
+ oldOid: oldEntry.oid,
652
+ });
653
+ }
654
+ }
655
+ }
656
+
657
+ return changes;
658
+ };
659
+
660
+ const readBlobText = async (s3: S3mini, repoId: string, oid: string): Promise<string> => {
661
+ const obj = await readGitObject(s3, repoId, oid);
662
+ if (!obj || obj.type !== "blob") return "";
663
+ return new TextDecoder().decode(obj.payload);
664
+ };
665
+
666
+ export const generateHTMLDiff = async (
667
+ s3: S3mini,
668
+ repoId: string,
669
+ hash: string,
670
+ ): Promise<string> => {
671
+ const { html, parse } = await import("diff2html");
672
+ const { createTwoFilesPatch } = await import("diff");
673
+
674
+ const commitObj = await readGitObject(s3, repoId, hash);
675
+ if (!commitObj || commitObj.type !== "commit") return "<p>Commit not found.</p>";
676
+ const parsed = parseCommit(commitObj.payload);
677
+ const parentOid = parsed.parents[0];
678
+
679
+ let parentTreeOid: string | undefined;
680
+ if (parentOid) {
681
+ const parentObj = await readGitObject(s3, repoId, parentOid);
682
+ if (parentObj && parentObj.type === "commit") {
683
+ parentTreeOid = parseCommit(parentObj.payload).tree;
684
+ }
685
+ }
686
+
687
+ const changes = parentTreeOid
688
+ ? await diffTrees(s3, repoId, parentTreeOid, parsed.tree)
689
+ : await diffTrees(s3, repoId, undefined, parsed.tree);
690
+
691
+ if (changes.length === 0) {
692
+ return "<p>No changes in this commit.</p>";
693
+ }
694
+
695
+ const chunks: string[] = [];
696
+ for (const change of changes) {
697
+ if (change.changeType === "added" && change.newOid) {
698
+ const content = await readBlobText(s3, repoId, change.newOid);
699
+ chunks.push(createTwoFilesPatch(`/dev/null`, `b/${change.filepath}`, "", content));
700
+ } else if (change.changeType === "deleted" && change.oldOid) {
701
+ const content = await readBlobText(s3, repoId, change.oldOid);
702
+ chunks.push(createTwoFilesPatch(`a/${change.filepath}`, `/dev/null`, content, ""));
703
+ } else if (change.changeType === "modified" && change.newOid && change.oldOid) {
704
+ const [oldContent, newContent] = await Promise.all([
705
+ readBlobText(s3, repoId, change.oldOid),
706
+ readBlobText(s3, repoId, change.newOid),
707
+ ]);
708
+ if (oldContent.length > 1024 * 512 || newContent.length > 1024 * 512) {
709
+ chunks.push(`diff --git a/${change.filepath} b/${change.filepath}\n`);
710
+ chunks.push(`--- a/${change.filepath}\n+++ b/${change.filepath}\n`);
711
+ chunks.push(`@@ -1 +1 @@\n-File too large to diff\n+File too large to diff\n`);
712
+ } else {
713
+ chunks.push(
714
+ createTwoFilesPatch(
715
+ `a/${change.filepath}`,
716
+ `b/${change.filepath}`,
717
+ oldContent,
718
+ newContent,
719
+ ),
720
+ );
721
+ }
722
+ }
723
+ }
724
+
725
+ const diffText = chunks.join("\n");
726
+ if (diffText.length > 1024 * 512) {
727
+ return "<p>Diff too large to display.</p>";
728
+ }
729
+
730
+ const diffJson = parse(diffText);
731
+ const diffHtml = html(diffJson, { drawFileList: true, matching: "lines" });
732
+ return diffHtml;
733
+ };
src/lib/markdown.ts ADDED
@@ -0,0 +1,30 @@
1
+ import rehypeExpressiveCode from "rehype-expressive-code";
2
+ import rehypeStringify from "rehype-stringify";
3
+ import remarkParse from "remark-parse";
4
+ import remarkRehype from "remark-rehype";
5
+ import { unified } from "unified";
6
+
7
+ const ecConfig = {
8
+ shiki: {
9
+ engine: "javascript" as const,
10
+ langAlias: {
11
+ h: "c", ino: "c", mm: "c", plum: "txt", mi: "txt",
12
+ scm: "txt", podspec: "txt", lock: "txt", xcconfig: "txt",
13
+ plist: "xml", xcworkspacedata: "xml", xcsettings: "xml",
14
+ storyboard: "txt", pbxproj: "txt", mjs: "javascript",
15
+ snap: "javascript", gradle: "groovy", class: "java",
16
+ mod: "go", sum: "go",
17
+ },
18
+ },
19
+ styleOverrides: { codeFontSize: "0.8rem", uiFontSize: "0.8rem" },
20
+ };
21
+
22
+ export const renderMarkdown = async (markdown: string) => {
23
+ const file = await unified()
24
+ .use(remarkParse)
25
+ .use(remarkRehype)
26
+ .use(rehypeExpressiveCode, ecConfig)
27
+ .use(rehypeStringify)
28
+ .process(markdown);
29
+ return String(file);
30
+ };
src/lib/repoContent.ts ADDED
@@ -0,0 +1,8 @@
1
+ import { S3mini } from 's3mini'
2
+
3
+ export const getRepoReadme = async (
4
+ s3: S3mini,
5
+ repoId: string,
6
+ ): Promise<string | undefined> => {
7
+ return (await s3.getObject(`${repoId}/README.md`)) ?? undefined
8
+ }
tsconfig.json CHANGED
@@ -7,6 +7,7 @@
7
7
  "skipLibCheck": true,
8
8
  "noEmit": true,
9
9
  "isolatedModules": true,
10
+ "allowImportingTsExtensions": true,
10
11
  "types": ["@cloudflare/workers-types"]
11
12
  },
12
13
  "include": ["src"]