website

#astro#js#html#css

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

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


d288f07 — pyrossh 2026-05-24T17:43:21+05:30
implement ssr

Generated files not shown: bun.lock

.gitignore CHANGED
@@ -56,4 +56,5 @@ dist-ssr
56
56
  *.njsproj
57
57
  *.sln
58
58
  *.sw?
59
- target
59
+ target
60
+ .wrangler
astro.config.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  // @ts-check
2
2
  import { defineConfig } from 'astro/config';
3
+ import node from '@astrojs/node';
3
4
  import icon from "astro-icon";
4
5
  import sitemap from '@astrojs/sitemap';
5
6
  import expressiveCode from 'astro-expressive-code';
@@ -7,10 +8,14 @@ import expressiveCode from 'astro-expressive-code';
7
8
  // https://astro.build/config
8
9
  export default defineConfig({
9
10
  site: 'https://pyrossh.dev',
11
+ output: 'server',
10
12
  trailingSlash: 'never',
13
+ adapter: node({
11
- output: 'static',
14
+ mode: 'standalone',
15
+ bodySizeLimit: 5 * 1024, // 5 MB
16
+ }),
12
17
  integrations: [icon(), sitemap(), expressiveCode()],
13
18
  devToolbar: {
14
19
  enabled: false,
15
20
  },
16
- });
21
+ });
package.json CHANGED
@@ -15,7 +15,8 @@
15
15
  },
16
16
  "dependencies": {
17
17
  "@appzic/astro-reset-css": "^1.2.0",
18
- "@astrojs/cloudflare": "^13.5.0",
18
+ "@astrojs/node": "^10.1.1",
19
+ "@aws-sdk/client-s3": "^3.1053.0",
19
20
  "alchemy": "^0.93.7",
20
21
  "astro": "6.3.1",
21
22
  "astro-color-scheme": "^1.1.6",
@@ -23,12 +24,12 @@
23
24
  "astro-icon": "^1.1.5",
24
25
  "pretty-bytes": "^7.1.0",
25
26
  "simple-git": "^3.27.0",
27
+ "spamscanner": "^6.1.5",
26
28
  "timeago.js": "^4.0.2"
27
29
  },
28
30
  "devDependencies": {
29
31
  "@astrojs/rss": "^4.0.18",
30
32
  "@astrojs/sitemap": "^3.7.2",
31
- "@aws-sdk/client-s3": "^3.932.0",
32
33
  "@aws-sdk/client-sts": "^3.932.0",
33
34
  "@iconify-json/material-symbols": "^1.2.39",
34
35
  "@iconify-json/mdi": "^1.2.3",
@@ -38,4 +39,4 @@
38
39
  "diff2html": "^3.4.52",
39
40
  "vscode-icons": "https://github.com/vscode-icons/vscode-icons.git"
40
41
  }
41
- }
42
+ }
src/actions/index.ts ADDED
@@ -0,0 +1,66 @@
1
+ import { defineAction } from "astro:actions";
2
+ import { z } from "astro/zod";
3
+ import {
4
+ addGitBugIssueComment,
5
+ createGitBugIssue,
6
+ setGitBugIssueState,
7
+ } from "@/utils/gitBug";
8
+
9
+ const repoId = z.string().regex(/^[a-zA-Z0-9._-]+$/);
10
+ const issueId = z.string().regex(/^[a-f0-9]{16}$/);
11
+
12
+ export const server = {
13
+ createIssue: defineAction({
14
+ accept: "form",
15
+ input: z.object({
16
+ repoId,
17
+ title: z.string().trim().min(1),
18
+ body: z.string().trim().optional(),
19
+ author: z.string().trim().optional(),
20
+ labels: z.string().trim().optional(),
21
+ }),
22
+ handler: async (input) => {
23
+ return await createGitBugIssue(input.repoId, {
24
+ title: input.title,
25
+ body: input.body,
26
+ author: input.author,
27
+ labels: input.labels
28
+ ?.split(",")
29
+ .map((label) => label.trim())
30
+ .filter(Boolean),
31
+ });
32
+ },
33
+ }),
34
+ commentOnIssue: defineAction({
35
+ accept: "form",
36
+ input: z.object({
37
+ repoId,
38
+ issueId,
39
+ body: z.string().trim().min(1),
40
+ author: z.string().trim().optional(),
41
+ }),
42
+ handler: async (input) => {
43
+ return await addGitBugIssueComment(input.repoId, input.issueId, {
44
+ body: input.body,
45
+ author: input.author,
46
+ });
47
+ },
48
+ }),
49
+ setIssueState: defineAction({
50
+ accept: "form",
51
+ input: z.object({
52
+ repoId,
53
+ issueId,
54
+ state: z.enum(["open", "closed"]),
55
+ author: z.string().trim().optional(),
56
+ }),
57
+ handler: async (input) => {
58
+ return await setGitBugIssueState(
59
+ input.repoId,
60
+ input.issueId,
61
+ input.state,
62
+ input.author,
63
+ );
64
+ },
65
+ }),
66
+ };
src/components/Issue.astro ADDED
@@ -0,0 +1,97 @@
1
+ ---
2
+ import TimeAgo from "./TimeAgo.astro";
3
+ import type { GitBugIssue } from "@/utils/gitBug";
4
+
5
+ type Props = {
6
+ repoId: string;
7
+ issue: GitBugIssue;
8
+ };
9
+
10
+ const { repoId, issue } = Astro.props;
11
+ const date = issue.updatedAt ?? issue.createdAt;
12
+ ---
13
+
14
+ <article class="issue">
15
+ <div class="issue-title">
16
+ <a href={`/repos/${repoId}/issues/${issue.id}`} title={issue.id}>
17
+ {issue.title}
18
+ </a>
19
+ <span class:list={["state", issue.state]}>{issue.state}</span>
20
+ </div>
21
+ <div class="meta">
22
+ <a href={`/repos/${repoId}/issues/${issue.id}`} title={issue.id}>
23
+ {issue.id.substring(0, 8)}
24
+ </a>
25
+ {issue.author && <span>by {issue.author}</span>}
26
+ {date && <TimeAgo date={date} />}
27
+ {issue.comments.length > 0 && <span>{issue.comments.length} comments</span>}
28
+ </div>
29
+ {
30
+ issue.labels.length > 0 && (
31
+ <div class="labels">
32
+ {issue.labels.map((label) => (
33
+ <span class="label">#{label}</span>
34
+ ))}
35
+ </div>
36
+ )
37
+ }
38
+ </article>
39
+
40
+ <style>
41
+ .issue {
42
+ background-color: var(--color-box-bg);
43
+ color: var(--color-text);
44
+ padding: 0.5rem;
45
+ margin: 0.5rem 0;
46
+ font-size: 11pt;
47
+
48
+ a {
49
+ color: var(--color-link);
50
+ }
51
+ }
52
+
53
+ .issue-title {
54
+ display: flex;
55
+ align-items: baseline;
56
+ gap: 0.5rem;
57
+ font-size: 1.05rem;
58
+ font-weight: 500;
59
+ }
60
+
61
+ .state {
62
+ border: 1px solid var(--color-text);
63
+ border-radius: 0.2rem;
64
+ padding: 0.05rem 0.35rem;
65
+ font-size: 0.75rem;
66
+ text-transform: uppercase;
67
+ }
68
+
69
+ .open {
70
+ color: var(--color-tag);
71
+ }
72
+
73
+ .closed {
74
+ opacity: 0.8;
75
+ }
76
+
77
+ .meta {
78
+ display: flex;
79
+ flex-wrap: wrap;
80
+ gap: 0.5rem;
81
+ margin-top: 0.25rem;
82
+ font-size: 0.85rem;
83
+ }
84
+
85
+ .labels {
86
+ display: flex;
87
+ flex-wrap: wrap;
88
+ gap: 0.35rem;
89
+ margin-top: 0.35rem;
90
+ }
91
+
92
+ .label {
93
+ color: var(--color-tag);
94
+ font-size: 0.85rem;
95
+ font-weight: 500;
96
+ }
97
+ </style>
src/layouts/BaseLayout.astro CHANGED
@@ -65,18 +65,6 @@ const canonicalURL = new URL(Astro.url.pathname, Astro.site);
65
65
  navigation: auto; /* enabled! */
66
66
  }
67
67
  </style>
68
- <script is:inline>
69
- if (window.__TAURI__) {
70
- window.__TAURI__.log.info("Back key triggered");
71
- window.__TAURI__.app.onBackButtonPress(() => {
72
- if (window.history.length > 1) {
73
- window.history.back();
74
- return true; // Mark event as handled
75
- }
76
- return false; // Let the app close
77
- });
78
- }
79
- </script>
80
68
  <Header />
81
69
  <Wrapper>
82
70
  <main class:list={classname}>
src/layouts/RepoLayout.astro CHANGED
@@ -72,6 +72,15 @@ const { pathname } = Astro.url;
72
72
  </a>
73
73
  </div>
74
74
  <div>|</div>
75
+ <div>
76
+ <a
77
+ aria-current={pathname.includes("/issues") ? "true" : "false"}
78
+ href={`/repos/${title}/issues`}
79
+ >
80
+ Issues
81
+ </a>
82
+ </div>
83
+ <div>|</div>
75
84
  <div>
76
85
  <a
77
86
  aria-current={pathname.includes("/files") ? "true" : "false"}
src/pages/posts/[...postId]/index.astro CHANGED
@@ -1,22 +1,19 @@
1
1
  ---
2
- import { type CollectionEntry, getCollection } from "astro:content";
2
+ import { getCollection } from "astro:content";
3
3
  import { render } from "astro:content";
4
4
  import Layout from "@/layouts/BaseLayout.astro";
5
5
  import FormattedDate from "@/components/FormattedDate.astro";
6
6
 
7
- export async function getStaticPaths() {
8
- const posts = await getCollection("content");
7
+ const posts = await getCollection("content");
8
+ const post = posts.find((item) => item.id === Astro.params.postId);
9
+ if (!post) {
9
- return posts.map((post) => ({
10
+ return Astro.redirect("/404");
10
- params: { postId: post.id },
11
- props: post,
12
- }));
13
11
  }
14
- type Props = CollectionEntry<"content">;
15
12
 
16
13
  const {
17
14
  data: { title, description, pubDate },
18
- } = Astro.props;
15
+ } = post;
19
- const { Content } = await render(Astro.props);
16
+ const { Content } = await render(post);
20
17
  ---
21
18
 
22
19
  <Layout title={title} description={description}>
src/pages/repos/[...repoId]/commits/[...hash]/index.astro CHANGED
@@ -1,35 +1,22 @@
1
1
  ---
2
- import { type CollectionEntry, getCollection } from "astro:content";
2
+ import { getCollection } from "astro:content";
3
3
  import RepoLayout from "@/layouts/RepoLayout.astro";
4
4
  import Commit from "@/components/Commit.astro";
5
- import type { Commit as CommitType } from "../../../../../utils/commit";
6
5
  import { generateHTMLDiff, getCommits } from "@/utils/commit";
7
6
  import "diff2html/bundles/css/diff2html.min.css";
8
7
 
9
- export async function getStaticPaths() {
10
- const repos = await getCollection("repos");
8
+ const repos = await getCollection("repos");
11
- const routes = [];
12
- for (const repo of repos) {
13
- const commits = await getCommits(repo.data.absolutePath);
9
+ const repo = repos.find((item) => item.id === Astro.params.repoId);
14
- for (const commit of commits) {
15
- routes.push({
16
- params: { repoId: repo.id, hash: commit.hash },
17
- props: {
18
- repo: repo,
10
+ if (!repo) {
19
- commit: commit,
20
- },
21
- });
22
- }
23
- }
24
- return routes;
11
+ return Astro.redirect("/404");
25
12
  }
26
13
 
27
- type Props = {
28
- repo: CollectionEntry<"repos">;
14
+ const commits = await getCommits(repo.data.absolutePath);
15
+ const commit = commits.find((item) => item.hash === Astro.params.hash);
29
- commit: CommitType;
16
+ if (!commit) {
17
+ return Astro.redirect(`/repos/${repo.id}/commits`);
30
- };
18
+ }
31
19
 
32
- const { repo, commit } = Astro.props;
33
20
  const diffHtml = await generateHTMLDiff(repo.data.absolutePath, commit.hash);
34
21
  ---
35
22
 
src/pages/repos/[...repoId]/commits/index.astro CHANGED
@@ -1,22 +1,15 @@
1
1
  ---
2
- import { type CollectionEntry, getCollection } from "astro:content";
2
+ import { getCollection } from "astro:content";
3
3
  import RepoLayout from "@/layouts/RepoLayout.astro";
4
4
  import Commit from "@/components/Commit.astro";
5
5
  import { getCommits } from "@/utils/commit";
6
6
 
7
- export async function getStaticPaths() {
8
- const repos = await getCollection("repos");
7
+ const repos = await getCollection("repos");
9
- return repos.map((repo) => ({
8
+ const repo = repos.find((item) => item.id === Astro.params.repoId);
10
- params: { repoId: repo.id },
11
- props: {
12
- repo: repo,
9
+ if (!repo) {
13
- }
14
- }));
10
+ return Astro.redirect("/404");
15
11
  }
16
- type Props = {
12
+
17
- repo: CollectionEntry<"repos">;
18
- }
19
- const { repo } = Astro.props;
20
13
  const commits = await getCommits(repo.data.absolutePath);
21
14
  ---
22
15
 
src/pages/repos/[...repoId]/files/[...file]/blame/index.astro CHANGED
@@ -1,37 +1,21 @@
1
1
  ---
2
- import { type CollectionEntry, getCollection } from "astro:content";
2
+ import { getCollection } from "astro:content";
3
3
  import FileLayout from "@/layouts/FileLayout.astro";
4
- import type { Commit as CommitType } from "@/utils/commit";
5
- import type { FileNode } from "@/utils/files";
6
4
  import { getFileHistory, getFiles } from "@/utils/commit";
7
5
 
8
- export async function getStaticPaths() {
9
- const repos = await getCollection("repos");
6
+ const repos = await getCollection("repos");
10
- const routes = [];
11
- for (const repo of repos) {
12
- const files = await getFiles(repo.data.absolutePath);
7
+ const repo = repos.find((item) => item.id === Astro.params.repoId);
13
- for (const file of files) {
14
- const history = await getFileHistory(repo.data.absolutePath, file.absolutePath);
15
- routes.push({
16
- params: { repoId: repo.id, file: file.name },
17
- props: {
18
- repo: repo,
8
+ if (!repo) {
19
- file: file,
20
- history: history,
21
- },
22
- });
23
- }
24
- }
25
- return routes;
9
+ return Astro.redirect("/404");
26
10
  }
27
11
 
28
- type Props = {
29
- repo: CollectionEntry<"repos">;
12
+ const files = await getFiles(repo.data.absolutePath);
13
+ const file = files.find((item) => item.name === Astro.params.file);
30
- file: FileNode;
14
+ if (!file) {
31
- history: CommitType[];
15
+ return Astro.redirect(`/repos/${repo.id}/files`);
32
- };
16
+ }
33
17
 
34
- const { repo, file, history } = Astro.props;
18
+ const history = await getFileHistory(repo.data.absolutePath, file.absolutePath);
35
19
  ---
36
20
 
37
21
  <FileLayout repo={repo} file={file}> WIP: Work in Progress... </FileLayout>
src/pages/repos/[...repoId]/files/[...file]/history/index.astro CHANGED
@@ -1,38 +1,22 @@
1
1
  ---
2
- import { type CollectionEntry, getCollection } from "astro:content";
2
+ import { getCollection } from "astro:content";
3
3
  import FileLayout from "@/layouts/FileLayout.astro";
4
4
  import Commit from "@/components/Commit.astro";
5
- import type { Commit as CommitType } from "@/utils/commit";
6
- import type { FileNode } from "@/utils/files";
7
5
  import { getFileHistory, getFiles } from "@/utils/commit";
8
6
 
9
- export async function getStaticPaths() {
10
- const repos = await getCollection("repos");
7
+ const repos = await getCollection("repos");
11
- const routes = [];
12
- for (const repo of repos) {
13
- const files = await getFiles(repo.data.absolutePath);
8
+ const repo = repos.find((item) => item.id === Astro.params.repoId);
14
- for (const file of files) {
15
- const history = await getFileHistory(repo.data.absolutePath, file.absolutePath);
16
- routes.push({
17
- params: { repoId: repo.id, file: file.name },
18
- props: {
19
- repo: repo,
9
+ if (!repo) {
20
- file: file,
21
- history: history,
22
- },
23
- });
24
- }
25
- }
26
- return routes;
10
+ return Astro.redirect("/404");
27
11
  }
28
12
 
29
- type Props = {
30
- repo: CollectionEntry<"repos">;
13
+ const files = await getFiles(repo.data.absolutePath);
14
+ const file = files.find((item) => item.name === Astro.params.file);
31
- file: FileNode;
15
+ if (!file) {
32
- history: CommitType[];
16
+ return Astro.redirect(`/repos/${repo.id}/files`);
33
- };
17
+ }
34
18
 
35
- const { repo, file, history } = Astro.props;
19
+ const history = await getFileHistory(repo.data.absolutePath, file.absolutePath);
36
20
  ---
37
21
 
38
22
  <FileLayout repo={repo} file={file}>
src/pages/repos/[...repoId]/files/[...file]/index.astro CHANGED
@@ -1,38 +1,22 @@
1
1
  ---
2
2
  import fs from "fs/promises";
3
- import { type CollectionEntry, getCollection } from "astro:content";
3
+ import { getCollection } from "astro:content";
4
4
  import { Code } from "astro-expressive-code/components";
5
5
  import FileLayout from "@/layouts/FileLayout.astro";
6
6
  import { getFiles } from "@/utils/commit";
7
- import type { FileNode } from "@/utils/files";
8
7
 
9
- export async function getStaticPaths() {
10
- const repos = await getCollection("repos");
8
+ const repos = await getCollection("repos");
11
- const routes = [];
12
- for (const repo of repos) {
13
- const files = await getFiles(repo.data.absolutePath);
9
+ const repo = repos.find((item) => item.id === Astro.params.repoId);
14
- for (const file of files) {
15
- routes.push({
16
- params: {
17
- repoId: repo.id,
18
- file: file.name,
19
- },
20
- props: {
21
- repo: repo,
10
+ if (!repo) {
22
- file: file,
23
- },
24
- });
25
- }
26
- }
27
- return routes;
11
+ return Astro.redirect("/404");
28
12
  }
29
13
 
30
- type Props = {
31
- repo: CollectionEntry<"repos">;
14
+ const files = await getFiles(repo.data.absolutePath);
15
+ const file = files.find((item) => item.name === Astro.params.file);
32
- file: FileNode;
16
+ if (!file) {
17
+ return Astro.redirect(`/repos/${repo.id}/files`);
33
- };
18
+ }
34
19
 
35
- const { repo, file } = Astro.props;
36
20
  const contentBuffer = await fs.readFile(file.absolutePath);
37
21
  const isBinary = ["apk", "dex", "ap_", "jar", "fnt"].includes(file.ext);
38
22
  const isLarge = file.size > 1024 * 512; // 512KB
src/pages/repos/[...repoId]/files/index.astro CHANGED
@@ -1,23 +1,16 @@
1
1
  ---
2
- import { type CollectionEntry, getCollection } from "astro:content";
2
+ import { getCollection } from "astro:content";
3
3
  import RepoLayout from "@/layouts/RepoLayout.astro";
4
4
  import RecursiveList from "@/components/RecursiveList.astro";
5
5
  import { getFiles } from "@/utils/commit";
6
6
  import { buildFileTree, sortChildren } from "@/utils/files";
7
7
 
8
- export async function getStaticPaths() {
9
- const repos = await getCollection("repos");
8
+ const repos = await getCollection("repos");
10
- return repos.map((repo) => ({
9
+ const repo = repos.find((item) => item.id === Astro.params.repoId);
11
- params: { repoId: repo.id },
12
- props: {
10
+ if (!repo) {
13
- repo,
11
+ return Astro.redirect("/404");
14
- },
15
- }));
16
12
  }
17
- type Props = {
13
+
18
- repo: CollectionEntry<"repos">;
19
- };
20
- const { repo } = Astro.props;
21
14
  const files = await getFiles(repo.data.absolutePath);
22
15
  const sortedFiles = sortChildren(buildFileTree(files));
23
16
  ---
src/pages/repos/[...repoId]/index.astro CHANGED
@@ -1,22 +1,14 @@
1
1
  ---
2
- import { type CollectionEntry, getCollection } from "astro:content";
2
+ import { getCollection } from "astro:content";
3
3
  import { render } from "astro:content";
4
4
  import RepoLayout from "@/layouts/RepoLayout.astro";
5
5
 
6
- export async function getStaticPaths() {
7
- const repos = await getCollection("repos");
6
+ const repos = await getCollection("repos");
8
- return repos.map((repo) => ({
7
+ const repo = repos.find((item) => item.id === Astro.params.repoId);
9
- params: { repoId: repo.id },
10
- props: {
11
- repo: repo,
8
+ if (!repo) {
12
- },
13
- }));
9
+ return Astro.redirect("/404");
14
10
  }
15
- type Props = {
16
- repo: CollectionEntry<"repos">;
17
- };
18
11
 
19
- const { repo } = Astro.props;
20
12
  const { Content } = await render(repo);
21
13
  ---
22
14
 
src/pages/repos/[...repoId]/issues/[...issueId]/index.astro ADDED
@@ -0,0 +1,159 @@
1
+ ---
2
+ import { type CollectionEntry, getCollection } from "astro:content";
3
+ import { actions } from "astro:actions";
4
+ import RepoLayout from "@/layouts/RepoLayout.astro";
5
+ import Issue from "@/components/Issue.astro";
6
+ import TimeAgo from "@/components/TimeAgo.astro";
7
+ import {
8
+ getGitBugIssue,
9
+ getGitBugIssues,
10
+ type GitBugIssue,
11
+ } from "@/utils/gitBug";
12
+
13
+ type Props = {
14
+ repo: CollectionEntry<"repos">;
15
+ issue: GitBugIssue;
16
+ };
17
+
18
+ const repos = await getCollection("repos");
19
+ const repo = repos.find((item) => item.id === Astro.params.repoId);
20
+ if (!repo) {
21
+ return Astro.redirect("/404");
22
+ }
23
+
24
+ const issue = await getGitBugIssue(repo.id, Astro.params.issueId ?? "");
25
+ if (!issue) {
26
+ return Astro.redirect(`/repos/${repo.id}/issues`);
27
+ }
28
+ ---
29
+
30
+ <RepoLayout repo={repo}>
31
+ <Issue repoId={repo.id} issue={issue} />
32
+ <form class="state-form" method="POST" action={actions.setIssueState}>
33
+ <input name="repoId" type="hidden" value={repo.id} />
34
+ <input name="issueId" type="hidden" value={issue.id} />
35
+ <input
36
+ name="state"
37
+ type="hidden"
38
+ value={issue.state === "open" ? "closed" : "open"}
39
+ />
40
+ <button type="submit">{issue.state === "open" ? "Close" : "Reopen"}</button>
41
+ </form>
42
+ {
43
+ issue.body && (
44
+ <section class="body">
45
+ <pre>{issue.body}</pre>
46
+ </section>
47
+ )
48
+ }
49
+ {
50
+ issue.comments.length > 0 && (
51
+ <section class="comments">
52
+ <h3>Comments</h3>
53
+ {issue.comments.map((comment) => (
54
+ <article class="comment">
55
+ <div class="comment-meta">
56
+ {comment.author && <strong>{comment.author}</strong>}
57
+ {comment.createdAt && <TimeAgo date={comment.createdAt} />}
58
+ </div>
59
+ <pre>{comment.body}</pre>
60
+ </article>
61
+ ))}
62
+ </section>
63
+ )
64
+ }
65
+ <form class="comment-form" method="POST" action={actions.commentOnIssue}>
66
+ <input name="repoId" type="hidden" value={repo.id} />
67
+ <input name="issueId" type="hidden" value={issue.id} />
68
+ <textarea name="body" placeholder="Comment" required></textarea>
69
+ <div class="comment-actions">
70
+ <input name="author" placeholder="Author" />
71
+ <button type="submit">Comment</button>
72
+ </div>
73
+ </form>
74
+ </RepoLayout>
75
+
76
+ <style>
77
+ .state-form,
78
+ .comment-form {
79
+ margin-top: 0.5rem;
80
+ }
81
+
82
+ button {
83
+ border: 1px solid var(--color-text);
84
+ background: var(--color-box-bg);
85
+ color: var(--color-link);
86
+ padding: 0.35rem 0.75rem;
87
+ font: inherit;
88
+ cursor: pointer;
89
+ }
90
+
91
+ .body,
92
+ .comments {
93
+ margin-top: 0.5rem;
94
+ }
95
+
96
+ .body,
97
+ .comment {
98
+ background-color: var(--color-box-bg);
99
+ color: var(--color-text);
100
+ padding: 0.5rem;
101
+ }
102
+
103
+ h3 {
104
+ margin: 0.5rem 0;
105
+ font-size: 1.05rem;
106
+ font-weight: 500;
107
+ }
108
+
109
+ .comment {
110
+ margin: 0.5rem 0;
111
+ }
112
+
113
+ .comment-meta {
114
+ display: flex;
115
+ flex-wrap: wrap;
116
+ gap: 0.5rem;
117
+ margin-bottom: 0.35rem;
118
+ font-size: 0.85rem;
119
+ }
120
+
121
+ pre {
122
+ white-space: pre-wrap;
123
+ overflow-x: auto;
124
+ margin: 0;
125
+ font-family: inherit;
126
+ }
127
+
128
+ .comment-form {
129
+ display: grid;
130
+ gap: 0.5rem;
131
+ padding: 0.5rem;
132
+ background: var(--color-box-bg);
133
+
134
+ textarea,
135
+ input {
136
+ width: 100%;
137
+ border: 1px solid var(--color-text);
138
+ background: var(--color-body-bg);
139
+ color: var(--color-text);
140
+ padding: 0.35rem 0.5rem;
141
+ font: inherit;
142
+ }
143
+
144
+ textarea {
145
+ min-height: 6rem;
146
+ resize: vertical;
147
+ }
148
+ }
149
+
150
+ .comment-actions {
151
+ display: grid;
152
+ grid-template-columns: minmax(0, 1fr) auto;
153
+ gap: 0.5rem;
154
+
155
+ @media (max-width: 720px) {
156
+ grid-template-columns: 1fr;
157
+ }
158
+ }
159
+ </style>
src/pages/repos/[...repoId]/issues/index.astro ADDED
@@ -0,0 +1,118 @@
1
+ ---
2
+ import { type CollectionEntry, getCollection } from "astro:content";
3
+ import { actions } from "astro:actions";
4
+ import RepoLayout from "@/layouts/RepoLayout.astro";
5
+ import Issue from "@/components/Issue.astro";
6
+ import { getGitBugIssues } from "@/utils/gitBug";
7
+
8
+ type Props = {
9
+ repo: CollectionEntry<"repos">;
10
+ };
11
+
12
+ const repos = await getCollection("repos");
13
+ const repo = repos.find((item) => item.id === Astro.params.repoId);
14
+ if (!repo) {
15
+ return Astro.redirect("/404");
16
+ }
17
+
18
+ const { issues, isAvailable } = await getGitBugIssues(repo.id);
19
+ const openIssues = issues.filter((issue) => issue.state === "open");
20
+ const closedIssues = issues.filter((issue) => issue.state === "closed");
21
+ ---
22
+
23
+ <RepoLayout repo={repo}>
24
+ <section class="issues">
25
+ <div class="summary">
26
+ <strong>{issues.length}</strong> issues
27
+ <span>{openIssues.length} open</span>
28
+ <span>{closedIssues.length} closed</span>
29
+ </div>
30
+ <form class="new-issue" method="POST" action={actions.createIssue}>
31
+ <input name="repoId" type="hidden" value={repo.id} />
32
+ <input name="title" placeholder="Title" required />
33
+ <textarea name="body" placeholder="Description"></textarea>
34
+ <div class="form-row">
35
+ <input name="author" placeholder="Author" />
36
+ <input name="labels" placeholder="labels, comma-separated" />
37
+ <button type="submit">Create</button>
38
+ </div>
39
+ </form>
40
+ {
41
+ issues.length > 0 ? (
42
+ issues.map((issue) => <Issue repoId={repo.id} issue={issue} />)
43
+ ) : (
44
+ <p class="empty">
45
+ {isAvailable
46
+ ? "No git-bug issues found in this repository."
47
+ : "No git-bug issues could be loaded from R2."}
48
+ </p>
49
+ )
50
+ }
51
+ </section>
52
+ </RepoLayout>
53
+
54
+ <style>
55
+ .issues {
56
+ margin-top: 0.5rem;
57
+ }
58
+
59
+ .summary {
60
+ display: flex;
61
+ flex-wrap: wrap;
62
+ gap: 0.5rem;
63
+ align-items: baseline;
64
+ padding: 0.5rem;
65
+ background: var(--color-box-bg);
66
+ color: var(--color-text);
67
+ font-size: 0.95rem;
68
+ }
69
+
70
+ .empty {
71
+ margin-top: 0.5rem;
72
+ padding: 0.5rem;
73
+ background: var(--color-box-bg);
74
+ color: var(--color-text);
75
+ }
76
+
77
+ .new-issue {
78
+ display: grid;
79
+ gap: 0.5rem;
80
+ margin: 0.5rem 0;
81
+ padding: 0.5rem;
82
+ background: var(--color-box-bg);
83
+
84
+ input,
85
+ textarea {
86
+ width: 100%;
87
+ border: 1px solid var(--color-text);
88
+ background: var(--color-body-bg);
89
+ color: var(--color-text);
90
+ padding: 0.35rem 0.5rem;
91
+ font: inherit;
92
+ }
93
+
94
+ textarea {
95
+ min-height: 6rem;
96
+ resize: vertical;
97
+ }
98
+
99
+ button {
100
+ border: 1px solid var(--color-text);
101
+ background: var(--color-box-bg);
102
+ color: var(--color-link);
103
+ padding: 0.35rem 0.75rem;
104
+ font: inherit;
105
+ cursor: pointer;
106
+ }
107
+ }
108
+
109
+ .form-row {
110
+ display: grid;
111
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
112
+ gap: 0.5rem;
113
+
114
+ @media (max-width: 720px) {
115
+ grid-template-columns: 1fr;
116
+ }
117
+ }
118
+ </style>
src/utils/commit.ts CHANGED
@@ -1,4 +1,4 @@
1
- import Diff2html from "diff2html";
1
+ import { html, parse } from "diff2html";
2
2
  import fs from "fs/promises";
3
3
  import path from "path";
4
4
  import { simpleGit } from "simple-git";
@@ -77,8 +77,8 @@ export const generateHTMLDiff = async (repoPath: string, hash: string): Promise<
77
77
  if (diff.length > 1024 * 512) {
78
78
  return '<p>Diff too large to display.</p>';
79
79
  }
80
- const diffJson = Diff2html.parse(diff);
80
+ const diffJson = parse(diff);
81
- const diffHtml = Diff2html.html(diffJson, {
81
+ const diffHtml = html(diffJson, {
82
82
  drawFileList: true,
83
83
  matching: "lines",
84
84
  });
src/utils/gitBug.ts ADDED
@@ -0,0 +1,328 @@
1
+ import {
2
+ GetObjectCommand,
3
+ ListObjectsV2Command,
4
+ PutObjectCommand,
5
+ S3Client,
6
+ } from "@aws-sdk/client-s3";
7
+
8
+ export type GitBugIssueState = "open" | "closed";
9
+
10
+ export interface GitBugIssue {
11
+ id: string;
12
+ title: string;
13
+ state: GitBugIssueState;
14
+ author?: string;
15
+ createdAt: string;
16
+ updatedAt: string;
17
+ labels: string[];
18
+ body?: string;
19
+ comments: GitBugComment[];
20
+ }
21
+
22
+ export interface GitBugComment {
23
+ id: string;
24
+ author?: string;
25
+ createdAt: string;
26
+ body: string;
27
+ }
28
+
29
+ export interface GitBugIssuesResult {
30
+ issues: GitBugIssue[];
31
+ isAvailable: boolean;
32
+ }
33
+
34
+ type SpamScanner = typeof import("spamscanner").default.prototype;
35
+
36
+ const safeRepoId = (repoId: string) => {
37
+ if (!/^[a-zA-Z0-9._-]+$/.test(repoId)) {
38
+ throw new Error("Invalid repository id.");
39
+ }
40
+ return repoId;
41
+ };
42
+
43
+ const safeIssueId = (issueId: string) => {
44
+ if (!/^[a-f0-9]{16}$/.test(issueId)) {
45
+ throw new Error("Invalid issue id.");
46
+ }
47
+ return issueId;
48
+ };
49
+
50
+ const makeIssueId = () => crypto.randomUUID().replaceAll("-", "").slice(0, 16);
51
+ const issuesPrefix = (repoId: string) => `${safeRepoId(repoId)}/issues/`;
52
+ const issueKey = (repoId: string, issueId: string) =>
53
+ `${issuesPrefix(repoId)}${safeIssueId(issueId)}.json`;
54
+ let spamScanner: SpamScanner | undefined;
55
+ let s3Client: S3Client | undefined;
56
+
57
+ const normalizeLabels = (labels?: string[]) =>
58
+ labels?.map((label) => label.trim()).filter(Boolean) ?? [];
59
+
60
+ const safeHeaderValue = (value: string) => value.replaceAll(/[\r\n]+/g, " ").trim();
61
+
62
+ const getSpamScanner = async () => {
63
+ if (!spamScanner) {
64
+ const { default: SpamScanner } = await import("spamscanner");
65
+ spamScanner = new SpamScanner({
66
+ enableAuthentication: false,
67
+ enableNsfwDetection: true,
68
+ enableReputation: true,
69
+ enableToxicityDetection: true,
70
+ timeout: 10_000,
71
+ });
72
+ }
73
+
74
+ return spamScanner;
75
+ };
76
+
77
+ const makeSpamScanEmail = (input: {
78
+ subject: string;
79
+ body?: string;
80
+ author?: string;
81
+ labels?: string[];
82
+ }) => {
83
+ const author = input.author?.trim() || "anonymous";
84
+ return [
85
+ "From: git-bug <[email protected]>",
86
87
+ `Subject: ${safeHeaderValue(input.subject)}`,
88
+ `X-GitBug-Author: ${safeHeaderValue(author)}`,
89
+ input.labels?.length
90
+ ? `X-GitBug-Labels: ${safeHeaderValue(input.labels.join(", "))}`
91
+ : undefined,
92
+ "",
93
+ input.body?.trim() ?? "",
94
+ ]
95
+ .filter((line) => line !== undefined)
96
+ .join("\n");
97
+ };
98
+
99
+ const assertNotSpam = async (input: {
100
+ subject: string;
101
+ body?: string;
102
+ author?: string;
103
+ labels?: string[];
104
+ }) => {
105
+ const scanner = await getSpamScanner();
106
+ const result = await scanner.scan(makeSpamScanEmail(input));
107
+
108
+ if (result.isSpam) {
109
+ throw new Error(`Spam detected: ${result.message}`);
110
+ }
111
+ };
112
+
113
+ const getR2Config = () => {
114
+ const bucket =
115
+ process.env.REPOS_R2_BUCKET ??
116
+ process.env.R2_BUCKET ??
117
+ process.env.AWS_BUCKET_NAME;
118
+ const endpoint =
119
+ process.env.R2_ENDPOINT ??
120
+ process.env.AWS_ENDPOINT_URL_S3 ??
121
+ (process.env.R2_ACCOUNT_ID
122
+ ? `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`
123
+ : undefined);
124
+ const accessKeyId =
125
+ process.env.R2_ACCESS_KEY_ID ?? process.env.AWS_ACCESS_KEY_ID;
126
+ const secretAccessKey =
127
+ process.env.R2_SECRET_ACCESS_KEY ?? process.env.AWS_SECRET_ACCESS_KEY;
128
+
129
+ if (!bucket || !endpoint || !accessKeyId || !secretAccessKey) {
130
+ throw new Error(
131
+ "Missing R2 configuration. Set REPOS_R2_BUCKET, R2_ENDPOINT or R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, and R2_SECRET_ACCESS_KEY.",
132
+ );
133
+ }
134
+
135
+ return { bucket, endpoint, accessKeyId, secretAccessKey };
136
+ };
137
+
138
+ const getS3Client = () => {
139
+ if (!s3Client) {
140
+ const { endpoint, accessKeyId, secretAccessKey } = getR2Config();
141
+ s3Client = new S3Client({
142
+ region: "auto",
143
+ endpoint,
144
+ credentials: {
145
+ accessKeyId,
146
+ secretAccessKey,
147
+ },
148
+ });
149
+ }
150
+
151
+ return s3Client;
152
+ };
153
+
154
+ const getObjectJson = async <T>(key: string) => {
155
+ const { bucket } = getR2Config();
156
+
157
+ try {
158
+ const object = await getS3Client().send(
159
+ new GetObjectCommand({
160
+ Bucket: bucket,
161
+ Key: key,
162
+ }),
163
+ );
164
+ const body = await object.Body?.transformToString();
165
+ return body ? (JSON.parse(body) as T) : undefined;
166
+ } catch (error) {
167
+ if (
168
+ error &&
169
+ typeof error === "object" &&
170
+ "name" in error &&
171
+ error.name === "NoSuchKey"
172
+ ) {
173
+ return undefined;
174
+ }
175
+
176
+ throw error;
177
+ }
178
+ };
179
+
180
+ const putIssue = async (repoId: string, issue: GitBugIssue) => {
181
+ const { bucket } = getR2Config();
182
+ await getS3Client().send(
183
+ new PutObjectCommand({
184
+ Bucket: bucket,
185
+ Key: issueKey(repoId, issue.id),
186
+ Body: JSON.stringify(issue, null, 2),
187
+ ContentType: "application/json; charset=utf-8",
188
+ }),
189
+ );
190
+ return issue;
191
+ };
192
+
193
+ export const getGitBugIssues = async (repoId: string): Promise<GitBugIssuesResult> => {
194
+ try {
195
+ const { bucket } = getR2Config();
196
+ const issues: GitBugIssue[] = [];
197
+ let continuationToken: string | undefined;
198
+
199
+ do {
200
+ const page = await getS3Client().send(
201
+ new ListObjectsV2Command({
202
+ Bucket: bucket,
203
+ Prefix: issuesPrefix(repoId),
204
+ ContinuationToken: continuationToken,
205
+ }),
206
+ );
207
+ const pageIssues = await Promise.all(
208
+ (page.Contents ?? [])
209
+ .filter((object) => object.Key?.endsWith(".json"))
210
+ .map(async (object) => {
211
+ return await getObjectJson<GitBugIssue>(object.Key ?? "");
212
+ }),
213
+ );
214
+
215
+ issues.push(
216
+ ...pageIssues.filter((issue): issue is GitBugIssue => Boolean(issue)),
217
+ );
218
+ continuationToken = page.IsTruncated
219
+ ? page.NextContinuationToken
220
+ : undefined;
221
+ } while (continuationToken);
222
+
223
+ return {
224
+ issues: issues.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)),
225
+ isAvailable: true,
226
+ };
227
+ } catch {
228
+ return {
229
+ issues: [],
230
+ isAvailable: false,
231
+ };
232
+ }
233
+ };
234
+
235
+ export const getGitBugIssue = async (
236
+ repoId: string,
237
+ issueId: string,
238
+ ): Promise<GitBugIssue | undefined> => {
239
+ return await getObjectJson<GitBugIssue>(issueKey(repoId, issueId));
240
+ };
241
+
242
+ export const createGitBugIssue = async (
243
+ repoId: string,
244
+ input: {
245
+ title: string;
246
+ body?: string;
247
+ author?: string;
248
+ labels?: string[];
249
+ },
250
+ ) => {
251
+ const labels = normalizeLabels(input.labels);
252
+ await assertNotSpam({
253
+ subject: input.title,
254
+ body: input.body,
255
+ author: input.author,
256
+ labels,
257
+ });
258
+
259
+ const now = new Date().toISOString();
260
+ const issue: GitBugIssue = {
261
+ id: makeIssueId(),
262
+ title: input.title.trim(),
263
+ state: "open",
264
+ author: input.author?.trim() || "anonymous",
265
+ createdAt: now,
266
+ updatedAt: now,
267
+ labels,
268
+ body: input.body?.trim(),
269
+ comments: [],
270
+ };
271
+
272
+ return await putIssue(repoId, issue);
273
+ };
274
+
275
+ export const addGitBugIssueComment = async (
276
+ repoId: string,
277
+ issueId: string,
278
+ input: {
279
+ body: string;
280
+ author?: string;
281
+ },
282
+ ) => {
283
+ const issue = await getGitBugIssue(repoId, issueId);
284
+ if (!issue) {
285
+ throw new Error("Issue not found.");
286
+ }
287
+
288
+ await assertNotSpam({
289
+ subject: `Comment on ${issue.title}`,
290
+ body: input.body,
291
+ author: input.author,
292
+ labels: issue.labels,
293
+ });
294
+
295
+ const now = new Date().toISOString();
296
+ return await putIssue(repoId, {
297
+ ...issue,
298
+ updatedAt: now,
299
+ comments: [
300
+ ...issue.comments,
301
+ {
302
+ id: makeIssueId(),
303
+ author: input.author?.trim() || "anonymous",
304
+ createdAt: now,
305
+ body: input.body.trim(),
306
+ },
307
+ ],
308
+ });
309
+ };
310
+
311
+ export const setGitBugIssueState = async (
312
+ repoId: string,
313
+ issueId: string,
314
+ state: GitBugIssueState,
315
+ author?: string,
316
+ ) => {
317
+ const issue = await getGitBugIssue(repoId, issueId);
318
+ if (!issue) {
319
+ throw new Error("Issue not found.");
320
+ }
321
+
322
+ return await putIssue(repoId, {
323
+ ...issue,
324
+ author: author?.trim() || issue.author,
325
+ state,
326
+ updatedAt: new Date().toISOString(),
327
+ });
328
+ };
wrangler.jsonc ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "$schema": "node_modules/wrangler/config-schema.json",
3
+ "name": "pyrossh-website",
4
+ "compatibility_date": "2026-05-15",
5
+ "r2_buckets": [
6
+ {
7
+ "binding": "REPOS",
8
+ "bucket_name": "pyrossh-repos-prd",
9
+ "preview_bucket_name": "pyrossh-repos-dev"
10
+ }
11
+ ]
12
+ }