website

#astro#js#html#css

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

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


60e980epyrossh 2026-08-05T17:52:57+05:30
improvements
src/components/theme-switcher.js → assets/client.js RENAMED
@@ -1,3 +1,6 @@
1
+ // All client-side JS the site ships, in one plain vanilla file — no
2
+ // bundler needed for two small, independent event listeners.
3
+
1
4
  // Wires up the theme button rendered as static markup in site-header.njk
2
5
  // (no web component, no framework — a stateless click-to-toggle button
3
6
  // doesn't need one). Loaded with `defer` so this runs after the DOM is
@@ -15,3 +18,18 @@ document.getElementById("theme-change").addEventListener("click", () => {
15
18
  root.setAttribute("data-theme", next);
16
19
  localStorage.setItem("theme", next);
17
20
  });
21
+
22
+ // .file-explorer-tree lives outside #file-pane (see the comment on
23
+ // .file-explorer in repos.css) so htmx swaps of #file-pane never re-render
24
+ // it — clicking a file never rebuilds the tree. That's good for keeping
25
+ // scroll position/expanded folders, but it also means the .active-file
26
+ // class painted at page-load time never moves on its own. Move it here,
27
+ // synchronously on click, instead of waiting on htmx's response.
28
+ document.addEventListener("click", (e) => {
29
+ const link = e.target.closest(".file-explorer-tree a");
30
+ if (!link) return;
31
+ const tree = link.closest(".file-explorer-tree");
32
+ const current = tree.querySelector("a.active-file");
33
+ if (current) current.classList.remove("active-file");
34
+ link.classList.add("active-file");
35
+ });
assets/css/shared.css CHANGED
@@ -72,7 +72,7 @@ table { border-collapse: collapse; }
72
72
  --color-text: light-dark(hsl(0, 0%, 13%), hsl(210, 17%, 98%));
73
73
  --color-link: light-dark(hsl(211, 100%, 45%), hsl(211, 100%, 60%));
74
74
  --color-post-link: light-dark(hsl(0, 0%, 0%), hsl(211, 100%, 60%));
75
- --color-highlight-bg: light-dark(hsl(183, 80%, 88%), hsl(183, 80%, 18%));
75
+ --color-highlight-bg: light-dark(hsl(210, 10%, 88%), hsl(210, 10%, 24%));
76
76
  --color-code-fg: light-dark(hsl(348, 86%, 43%), hsl(348, 86%, 80%));
77
77
  --color-code-bg: light-dark(hsl(0, 0%, 89%), hsl(210, 13%, 6%));
78
78
  --color-pre-bg: light-dark(hsl(0, 0%, 89%), hsl(210, 13%, 6%));
@@ -331,10 +331,14 @@ footer {
331
331
  Prism-highlighted blog-post code and plain markdown-it README code
332
332
  fences). It's genuinely cross-cutting rather than owned by one
333
333
  component — scoping it to either would mean duplicating the whole token
334
- palette in the other — so it stays global. */
334
+ palette in the other — so it stays global.
335
+
336
+ Background lives on pre.code-view/.markdown-body pre below, not here:
337
+ code[class*="language-"] is inline and wraps a whole multi-line file, so
338
+ giving IT a background paints one box per line box (only as wide as that
339
+ line's text) instead of one clean rectangle behind the block. */
335
340
  pre[class*="language-"],
336
341
  code[class*="language-"] {
337
- background: var(--color-pre-bg);
338
342
  color: var(--color-text);
339
343
  }
340
344
 
assets/css/workers/home.css CHANGED
@@ -30,14 +30,9 @@
30
30
 
31
31
  .repos {
32
32
  display: grid;
33
- grid-template-columns: auto auto;
33
+ grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
34
34
  gap: 1rem;
35
35
  margin-top: 0.5rem;
36
-
37
- @media (max-width: 720px) {
38
- gap: 1rem;
39
- grid-template-columns: auto;
40
- }
41
36
  }
42
37
 
43
38
  .title {
@@ -63,12 +58,8 @@
63
58
 
64
59
  .gridContainer {
65
60
  display: grid;
66
- grid-template-columns: repeat(1, minmax(0, 1fr));
61
+ grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
67
62
  gap: var(--space-gap-fluid);
68
-
69
- @media (min-width: 640px) {
70
- grid-template-columns: repeat(2, minmax(0, 1fr));
71
- }
72
63
  }
73
64
 
74
65
  .repo {
@@ -168,14 +159,10 @@
168
159
  ul {
169
160
  display: grid;
170
161
  gap: 0.5rem;
171
- grid-template-columns: auto auto auto auto;
162
+ grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
172
163
  text-align: center;
173
164
  margin-top: 0.5rem;
174
165
 
175
- @media (max-width: 720px) {
176
- grid-template-columns: auto auto auto;
177
- }
178
-
179
166
  li {
180
167
  display: flex;
181
168
  align-items: center;
assets/css/workers/issues-detail.css CHANGED
@@ -73,10 +73,6 @@ textarea {
73
73
 
74
74
  .comment-actions {
75
75
  display: grid;
76
- grid-template-columns: minmax(0, 1fr) auto;
76
+ grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
77
77
  gap: 0.5rem;
78
-
79
- @media (max-width: 720px) {
80
- grid-template-columns: 1fr;
81
- }
82
78
  }
assets/css/workers/issues-index.css CHANGED
@@ -54,10 +54,6 @@ button {
54
54
 
55
55
  .form-row {
56
56
  display: grid;
57
- grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
57
+ grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
58
58
  gap: 0.5rem;
59
-
60
- @media (max-width: 720px) {
61
- grid-template-columns: 1fr;
62
- }
63
59
  }
assets/css/workers/posts-index.css CHANGED
@@ -16,18 +16,13 @@
16
16
 
17
17
  li {
18
18
  display: grid;
19
- grid-template-columns: repeat(1, minmax(0, 1fr));
19
+ grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
20
- gap: 0.4rem;
20
+ gap: 0.6rem;
21
21
  justify-items: start;
22
22
  align-items: end;
23
23
  margin-top: 1.25rem;
24
24
  line-height: 1.5rem;
25
25
 
26
- @media (min-width: 640px) {
27
- grid-template-columns: repeat(2, minmax(0, 1fr));
28
- gap: 0.75rem;
29
- }
30
-
31
26
  a {
32
27
  font-size: 1.1rem;
33
28
  color: var(--color-post-link);
@@ -58,9 +53,6 @@ time {
58
53
  justify-content: flex-end;
59
54
  font-size: 1rem;
60
55
  line-height: 1.5rem;
61
- @media (min-width: 640px) {
62
- margin-left: 1rem;
63
- }
64
56
  }
65
57
  }
66
58
  }
eleventy.config.js CHANGED
@@ -47,13 +47,6 @@ export default function (eleventyConfig) {
47
47
  "node_modules/diff2html/bundles/css/diff2html.min.css": "assets/css/diff2html.min.css",
48
48
  });
49
49
 
50
- // theme-switcher is the one bit of client-side JS the site ships (real
51
- // click + localStorage behavior) — plain vanilla script, no bundler
52
- // needed.
53
- eleventyConfig.addPassthroughCopy({
54
- "src/components/theme-switcher.js": "assets/components/theme-switcher.js",
55
- });
56
-
57
50
  // htmx powers no-full-page-reload tab switching on repo pages (Readme/
58
51
  // Commits/Files, Contents/History/Blame) — hx-select pulls #repo-content
59
52
  // or #file-content out of the fetched static page and swaps it in place.
infra/main.tf CHANGED
@@ -66,7 +66,7 @@ resource "terraform_data" "sync_website" {
66
66
  depends_on = [cloudflare_r2_bucket.website, terraform_data.build_website]
67
67
 
68
68
  provisioner "local-exec" {
69
- command = "rclone sync ${path.module}/../dist website:${cloudflare_r2_bucket.website.name} --checksum --fast-list -P"
69
+ command = "rclone sync ${path.module}/../dist website:${cloudflare_r2_bucket.website.name} --checksum --fast-list --multi-thread-streams=32 -P "
70
70
  }
71
71
  }
72
72
 
infra/terraform.tfstate CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 4,
3
3
  "terraform_version": "1.13.3",
4
- "serial": 23,
4
+ "serial": 29,
5
5
  "lineage": "6cb48eaa-b069-089b-fce3-e277626defab",
6
6
  "outputs": {
7
7
  "bucket_name": {
@@ -79,8 +79,8 @@
79
79
  "jurisdiction": "default",
80
80
  "min_tls": null,
81
81
  "status": {
82
- "ownership": "pending",
82
+ "ownership": "active",
83
- "ssl": "initializing"
83
+ "ssl": "active"
84
84
  },
85
85
  "zone_id": "e95a5d8957b75859c814579deebfce33",
86
86
  "zone_name": "pyrossh.dev"
@@ -133,12 +133,12 @@
133
133
  {
134
134
  "schema_version": 0,
135
135
  "attributes": {
136
- "id": "06e5fdf0-4974-c673-b975-2f3fa99846fc",
136
+ "id": "f64c9052-0f71-5705-a183-e85f891292b2",
137
137
  "input": null,
138
138
  "output": null,
139
139
  "triggers_replace": {
140
140
  "value": [
141
- "2026-08-05T05:36:25Z"
141
+ "2026-08-05T06:08:43Z"
142
142
  ],
143
143
  "type": [
144
144
  "tuple",
@@ -156,19 +156,53 @@
156
156
  {
157
157
  "mode": "managed",
158
158
  "type": "terraform_data",
159
- "name": "sync_website",
159
+ "name": "purge_cache",
160
160
  "provider": "provider[\"terraform.io/builtin/terraform\"]",
161
161
  "instances": [
162
162
  {
163
163
  "status": "tainted",
164
164
  "schema_version": 0,
165
165
  "attributes": {
166
- "id": "2036dc20-874b-06df-a0e0-481ee25c8d06",
166
+ "id": "7173b400-cbc8-069b-7c74-6fa0bae8b3e2",
167
+ "input": null,
168
+ "output": null,
169
+ "triggers_replace": {
170
+ "value": [
171
+ "2026-08-05T06:27:38Z"
172
+ ],
173
+ "type": [
174
+ "tuple",
175
+ [
176
+ "string"
177
+ ]
178
+ ]
179
+ }
180
+ },
181
+ "sensitive_attributes": [],
182
+ "identity_schema_version": 0,
183
+ "dependencies": [
184
+ "cloudflare_r2_bucket.website",
185
+ "terraform_data.build_website",
186
+ "terraform_data.sync_website"
187
+ ]
188
+ }
189
+ ]
190
+ },
191
+ {
192
+ "mode": "managed",
193
+ "type": "terraform_data",
194
+ "name": "sync_website",
195
+ "provider": "provider[\"terraform.io/builtin/terraform\"]",
196
+ "instances": [
197
+ {
198
+ "schema_version": 0,
199
+ "attributes": {
200
+ "id": "1fe8b333-1b40-c0c6-b113-1e7ce0519af8",
167
201
  "input": null,
168
202
  "output": null,
169
203
  "triggers_replace": {
170
204
  "value": [
171
- "2026-08-05T05:36:38Z"
205
+ "2026-08-05T06:09:03Z"
172
206
  ],
173
207
  "type": [
174
208
  "tuple",
infra/terraform.tfstate.backup CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 4,
3
3
  "terraform_version": "1.13.3",
4
- "serial": 17,
4
+ "serial": 23,
5
5
  "lineage": "6cb48eaa-b069-089b-fce3-e277626defab",
6
6
  "outputs": {
7
7
  "bucket_name": {
@@ -62,6 +62,37 @@
62
62
  }
63
63
  ]
64
64
  },
65
+ {
66
+ "mode": "managed",
67
+ "type": "cloudflare_r2_custom_domain",
68
+ "name": "git",
69
+ "provider": "provider[\"registry.terraform.io/cloudflare/cloudflare\"]",
70
+ "instances": [
71
+ {
72
+ "schema_version": 500,
73
+ "attributes": {
74
+ "account_id": "dfdb8ca4e419990852eb214c5b50bd5e",
75
+ "bucket_name": "git",
76
+ "ciphers": null,
77
+ "domain": "git.pyrossh.dev",
78
+ "enabled": true,
79
+ "jurisdiction": "default",
80
+ "min_tls": null,
81
+ "status": {
82
+ "ownership": "pending",
83
+ "ssl": "initializing"
84
+ },
85
+ "zone_id": "e95a5d8957b75859c814579deebfce33",
86
+ "zone_name": "pyrossh.dev"
87
+ },
88
+ "sensitive_attributes": [],
89
+ "identity_schema_version": 0,
90
+ "dependencies": [
91
+ "cloudflare_r2_bucket.git"
92
+ ]
93
+ }
94
+ ]
95
+ },
65
96
  {
66
97
  "mode": "managed",
67
98
  "type": "cloudflare_r2_custom_domain",
@@ -102,12 +133,12 @@
102
133
  {
103
134
  "schema_version": 0,
104
135
  "attributes": {
105
- "id": "82dcd6f4-f2c5-a69f-b707-8db44fc9f2e8",
136
+ "id": "06e5fdf0-4974-c673-b975-2f3fa99846fc",
106
137
  "input": null,
107
138
  "output": null,
108
139
  "triggers_replace": {
109
140
  "value": [
110
- "2026-08-05T05:24:58Z"
141
+ "2026-08-05T05:36:25Z"
111
142
  ],
112
143
  "type": [
113
144
  "tuple",
@@ -132,12 +163,12 @@
132
163
  "status": "tainted",
133
164
  "schema_version": 0,
134
165
  "attributes": {
135
- "id": "a7aaeeec-5a16-6c2e-17e4-133b6d69deef",
166
+ "id": "2036dc20-874b-06df-a0e0-481ee25c8d06",
136
167
  "input": null,
137
168
  "output": null,
138
169
  "triggers_replace": {
139
170
  "value": [
140
- "2026-08-05T05:25:10Z"
171
+ "2026-08-05T05:36:38Z"
141
172
  ],
142
173
  "type": [
143
174
  "tuple",
package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "pyrossh-dev",
3
3
  "type": "module",
4
4
  "scripts": {
5
- "dev": "eleventy --serve",
5
+ "dev": "node --max-old-space-size=8192 ./node_modules/.bin/eleventy --serve",
6
6
  "build": "node --max-old-space-size=8192 ./node_modules/.bin/eleventy",
7
7
  "tf:plan": "terraform -chdir=infra plan",
8
8
  "tf:apply": "terraform -chdir=infra apply"
src/_helpers/fileIcons.js CHANGED
@@ -1,5 +1,47 @@
1
1
  import iconSet from "@iconify-json/vscode-icons/icons.json" with { type: "json" };
2
2
 
3
+ // Chrome (confirmed in headless and in production) never paints a
4
+ // gradient-filled path that lives inside a <symbol> instantiated through
5
+ // <use> — the same markup renders fine as a standalone inline <svg>, so
6
+ // this is specific to the symbol/use indirection the sprite relies on.
7
+ // vscode-icons ships several file-type icons (rust, license, webp, python,
8
+ // kotlin, the "dist" folder) as gradients, so rather than special-case
9
+ // each one, every icon's gradients are resolved to one flat representative
10
+ // color at build time before being embedded in the sprite. Barely
11
+ // noticeable at the 16px size these render at, and keeps the one-symbol-
12
+ // shared-by-every-row sprite this module is built around (see
13
+ // iconSpriteDefs below) instead of duplicating full icon bodies per file.
14
+ const GRADIENT_TAG_RE = /<(linearGradient|radialGradient)\b([^>]*?)(?:\/>|>([\s\S]*?)<\/\1>)/g;
15
+ const STOP_COLOR_RE = /<stop\b[^>]*\bstop-color="([^"]+)"[^>]*\/?>/g;
16
+ const ATTR_RE = (attr) => new RegExp(`\\b${attr}="([^"]+)"`);
17
+
18
+ const flattenGradients = (body) => {
19
+ if (!body.includes("Gradient")) return body;
20
+
21
+ const gradients = new Map();
22
+ for (const match of body.matchAll(GRADIENT_TAG_RE)) {
23
+ const [, , attrs, inner] = match;
24
+ const id = attrs.match(ATTR_RE("id"))?.[1];
25
+ if (!id) continue;
26
+ const href = attrs.match(ATTR_RE("href"))?.[1]?.replace(/^#/, "");
27
+ const stops = inner ? [...inner.matchAll(STOP_COLOR_RE)].map((m) => m[1]) : [];
28
+ gradients.set(id, { href, stops });
29
+ }
30
+
31
+ const resolveColor = (id, seen = new Set()) => {
32
+ if (seen.has(id)) return null;
33
+ seen.add(id);
34
+ const g = gradients.get(id);
35
+ if (!g) return null;
36
+ if (g.stops.length) return g.stops[Math.floor(g.stops.length / 2)];
37
+ return g.href ? resolveColor(g.href, seen) : null;
38
+ };
39
+
40
+ return body
41
+ .replace(GRADIENT_TAG_RE, "")
42
+ .replace(/(fill|stroke)="url\(#([^)]+)\)"/g, (whole, prop, id) => `${prop}="${resolveColor(id) ?? "#888"}"`);
43
+ };
44
+
3
45
  // The old Astro version resolved per-extension file/folder icons from the
4
46
  // full vscode-icons manifest (see git history at 7325531^:src/utils/files.ts).
5
47
  // That manifest is itself a large VS Code extension repo installed as a git
@@ -89,7 +131,7 @@ export const iconSpriteDefs = () => {
89
131
  const symbols = [...ICON_NAMES]
90
132
  .map((name) => {
91
133
  const icon = iconSet.icons[name];
92
- return `<symbol id="icon-${name}" viewBox="0 0 ${iconSet.width} ${iconSet.height}">${icon.body}</symbol>`;
134
+ return `<symbol id="icon-${name}" viewBox="0 0 ${iconSet.width} ${iconSet.height}">${flattenGradients(icon.body)}</symbol>`;
93
135
  })
94
136
  .join("");
95
137
  return `<svg xmlns="http://www.w3.org/2000/svg" style="display:none" aria-hidden="true">${symbols}</svg>`;
src/_includes/layout.njk CHANGED
@@ -21,7 +21,7 @@
21
21
  <meta property="og:description" content="{{ description or site.description }}" />
22
22
  <meta property="og:image" content="/assets/icons/icon.svg" />
23
23
  <meta name="twitter:card" content="summary_large_image" />
24
- <script defer src="/assets/components/theme-switcher.js"></script>
24
+ <script defer src="/assets/client.js"></script>
25
25
  <script src="/assets/vendor/htmx.min.js"></script>
26
26
  </head>
27
27
  <body>
src/_includes/repo-layout.njk CHANGED
@@ -10,8 +10,8 @@
10
10
  <hr />
11
11
  <div class="nav">
12
12
  <div><a href="/repos/{{ repo }}/" hx-get="/repos/{{ repo }}/" hx-select="#repo-page" hx-target="#repo-page" hx-swap="outerHTML" hx-push-url="true" class="{% if tab == 'Readme' %}active-tab{% endif %}">Readme</a><span>|</span></div>
13
- <div><a href="/repos/{{ repo }}/commits/" hx-get="/repos/{{ repo }}/commits/" hx-select="#repo-page" hx-target="#repo-page" hx-swap="outerHTML" hx-push-url="true" class="{% if tab == 'Commits' %}active-tab{% endif %}">Commits</a><span>|</span></div>
14
- <div><a href="/repos/{{ repo }}/files/" hx-get="/repos/{{ repo }}/files/" hx-select="#repo-page" hx-target="#repo-page" hx-swap="outerHTML" hx-push-url="true" class="{% if tab == 'Files' %}active-tab{% endif %}">Files</a></div>
13
+ <div><a href="/repos/{{ repo }}/files/" hx-get="/repos/{{ repo }}/files/" hx-select="#repo-page" hx-target="#repo-page" hx-swap="outerHTML" hx-push-url="true" class="{% if tab == 'Files' %}active-tab{% endif %}">Code</a><span>|</span></div>
14
+ <div><a href="/repos/{{ repo }}/commits/" hx-get="/repos/{{ repo }}/commits/" hx-select="#repo-page" hx-target="#repo-page" hx-swap="outerHTML" hx-push-url="true" class="{% if tab == 'Commits' %}active-tab{% endif %}">Commits</a></div>
15
15
  </div>
16
16
  </div>
17
17
  {% endmacro %}
src/repos/files-index.njk CHANGED
@@ -6,7 +6,7 @@ pagination:
6
6
  permalink: "/repos/{{ repo.id }}/files/"
7
7
  layout: layout.njk
8
8
  eleventyComputed:
9
- title: "Files | {{ repo.data.title }}"
9
+ title: "Code | {{ repo.data.title }}"
10
10
  ---
11
11
  {% from "repo-layout.njk" import repoLayout %}
12
12
  {% from "file-layout.njk" import fileLayout, fileContent %}