website

#astro#js#html#css

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

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


09c6a56pyrossh 2026-08-06T09:59:15+05:30
moar improvements
assets/css/repos.css CHANGED
@@ -55,6 +55,14 @@
55
55
  flex: 1;
56
56
  }
57
57
 
58
+ .repo-version {
59
+ font-size: 1.1rem;
60
+ font-weight: 500;
61
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
62
+ color: var(--color-text);
63
+ margin-left: 0.5rem;
64
+ }
65
+
58
66
  .tags {
59
67
  margin-top: 0.5rem;
60
68
  display: flex;
assets/css/shared.css CHANGED
@@ -191,11 +191,11 @@ main {
191
191
  .wrapper {
192
192
  flex: 1;
193
193
  width: 100%;
194
- max-width: 960px;
194
+ max-width: 72ch;
195
195
  margin-right: auto;
196
196
  margin-left: auto;
197
- padding-left: 1em;
197
+ padding-left: 1rem;
198
- padding-right: 1em;
198
+ padding-right: 1rem;
199
199
  }
200
200
 
201
201
  /* site-header.njk/site-footer.njk are plain includes (no wrapping custom
package-lock.json CHANGED
@@ -15,7 +15,8 @@
15
15
  "@11ty/eleventy": "^3.0.0",
16
16
  "@11ty/eleventy-plugin-rss": "^2.0.0",
17
17
  "@11ty/eleventy-plugin-syntaxhighlight": "^5.0.0",
18
- "@iconify-json/vscode-icons": "^1.2.68"
18
+ "@iconify-json/vscode-icons": "^1.2.68",
19
+ "playwright": "^1.62.1"
19
20
  }
20
21
  },
21
22
  "node_modules/@11ty/dependency-tree": {
@@ -1347,6 +1348,53 @@
1347
1348
  "url": "https://github.com/sponsors/jonschlinkert"
1348
1349
  }
1349
1350
  },
1351
+ "node_modules/playwright": {
1352
+ "version": "1.62.1",
1353
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
1354
+ "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
1355
+ "dev": true,
1356
+ "license": "Apache-2.0",
1357
+ "dependencies": {
1358
+ "playwright-core": "1.62.1"
1359
+ },
1360
+ "bin": {
1361
+ "playwright": "cli.js"
1362
+ },
1363
+ "engines": {
1364
+ "node": ">=20"
1365
+ },
1366
+ "optionalDependencies": {
1367
+ "fsevents": "2.3.2"
1368
+ }
1369
+ },
1370
+ "node_modules/playwright-core": {
1371
+ "version": "1.62.1",
1372
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
1373
+ "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
1374
+ "dev": true,
1375
+ "license": "Apache-2.0",
1376
+ "bin": {
1377
+ "playwright-core": "cli.js"
1378
+ },
1379
+ "engines": {
1380
+ "node": ">=20"
1381
+ }
1382
+ },
1383
+ "node_modules/playwright/node_modules/fsevents": {
1384
+ "version": "2.3.2",
1385
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
1386
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
1387
+ "dev": true,
1388
+ "hasInstallScript": true,
1389
+ "license": "MIT",
1390
+ "optional": true,
1391
+ "os": [
1392
+ "darwin"
1393
+ ],
1394
+ "engines": {
1395
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
1396
+ }
1397
+ },
1350
1398
  "node_modules/please-upgrade-node": {
1351
1399
  "version": "3.2.0",
1352
1400
  "resolved": "https://registry.npmjs.org/please-upgrade-node/-/please-upgrade-node-3.2.0.tgz",
package.json CHANGED
@@ -4,6 +4,10 @@
4
4
  "scripts": {
5
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
+ "pretest": "npm run build",
8
+ "test": "node --test tests/**/*.test.js",
9
+ "test:install-browsers": "playwright install --with-deps chromium",
10
+ "archive:submit": "node scripts/submit-to-archives.js",
7
11
  "tf:plan": "terraform -chdir=infra plan",
8
12
  "tf:apply": "terraform -chdir=infra apply"
9
13
  },
@@ -17,6 +21,7 @@
17
21
  "@11ty/eleventy": "^3.0.0",
18
22
  "@11ty/eleventy-plugin-rss": "^2.0.0",
19
23
  "@11ty/eleventy-plugin-syntaxhighlight": "^5.0.0",
20
- "@iconify-json/vscode-icons": "^1.2.68"
24
+ "@iconify-json/vscode-icons": "^1.2.68",
25
+ "playwright": "^1.62.1"
21
26
  }
22
27
  }
scripts/submit-to-archives.js ADDED
@@ -0,0 +1,134 @@
1
+ #!/usr/bin/env node
2
+ // Asks public web archives to snapshot the key pages of the site.
3
+ //
4
+ // Providers:
5
+ // - Wayback Machine (web.archive.org) via its "Save Page Now" endpoint.
6
+ // Set ARCHIVE_ORG_ACCESS_KEY / ARCHIVE_ORG_SECRET_KEY (from
7
+ // https://archive.org/account/s3.php) to use the authenticated SPN2 API,
8
+ // which is less aggressively rate-limited and reports a job status.
9
+ // Without them, falls back to the unauthenticated /save/<url> endpoint.
10
+ // - archive.today (archive.ph) via its public /submit/ form endpoint.
11
+ // There's no official API; archive.today occasionally interposes a
12
+ // captcha/"please wait" page under load, in which case the submission
13
+ // just gets logged as needing a manual follow-up.
14
+ //
15
+ // Usage:
16
+ // node scripts/submit-to-archives.js [--provider=wayback|archivetoday|all] [--dry-run] [--url=/some/path/]
17
+
18
+ import { readdirSync } from "node:fs";
19
+ import path from "node:path";
20
+ import { fileURLToPath } from "node:url";
21
+ import { setTimeout as sleep } from "node:timers/promises";
22
+
23
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
24
+ const SITE_URL = "https://pyrossh.dev";
25
+ const DELAY_BETWEEN_REQUESTS_MS = 4000;
26
+
27
+ function parseArgs(argv) {
28
+ const args = { provider: "all", dryRun: false, urls: [] };
29
+ for (const arg of argv) {
30
+ if (arg === "--dry-run") args.dryRun = true;
31
+ else if (arg.startsWith("--provider=")) args.provider = arg.slice("--provider=".length);
32
+ else if (arg.startsWith("--url=")) args.urls.push(arg.slice("--url=".length));
33
+ }
34
+ return args;
35
+ }
36
+
37
+ function discoverPostSlugs() {
38
+ const postsDir = path.join(__dirname, "..", "src", "posts");
39
+ return readdirSync(postsDir)
40
+ .filter((file) => file.endsWith(".md"))
41
+ .map((file) => file.replace(/\.md$/, ""));
42
+ }
43
+
44
+ function discoverUrls() {
45
+ const staticPaths = [
46
+ "/",
47
+ "/cv/",
48
+ "/posts/",
49
+ "/only-bible-app/",
50
+ "/only-bible-app/privacy-policy/",
51
+ "/only-bible-app/terms-and-conditions/",
52
+ ];
53
+ const postPaths = discoverPostSlugs().map((slug) => `/posts/${slug}/`);
54
+ return [...staticPaths, ...postPaths].map((p) => `${SITE_URL}${p}`);
55
+ }
56
+
57
+ async function submitToWaybackMachine(url) {
58
+ const accessKey = process.env.ARCHIVE_ORG_ACCESS_KEY;
59
+ const secretKey = process.env.ARCHIVE_ORG_SECRET_KEY;
60
+
61
+ if (accessKey && secretKey) {
62
+ const response = await fetch("https://web.archive.org/save", {
63
+ method: "POST",
64
+ headers: {
65
+ Authorization: `LOW ${accessKey}:${secretKey}`,
66
+ "content-type": "application/x-www-form-urlencoded",
67
+ Accept: "application/json",
68
+ },
69
+ body: new URLSearchParams({ url, skip_first_archive: "1" }),
70
+ });
71
+ const body = await response.json().catch(() => ({}));
72
+ return { ok: response.ok, status: response.status, detail: body.job_id ? `job ${body.job_id}` : JSON.stringify(body) };
73
+ }
74
+
75
+ const response = await fetch(`https://web.archive.org/save/${url}`, { method: "GET", redirect: "follow" });
76
+ return { ok: response.ok, status: response.status, detail: response.url };
77
+ }
78
+
79
+ async function submitToArchiveToday(url) {
80
+ const response = await fetch("https://archive.ph/submit/", {
81
+ method: "POST",
82
+ headers: { "content-type": "application/x-www-form-urlencoded" },
83
+ body: new URLSearchParams({ url, anyway: "1" }),
84
+ redirect: "manual",
85
+ });
86
+ const location = response.headers.get("location");
87
+ const isRedirect = response.status >= 300 && response.status < 400;
88
+ return {
89
+ ok: response.ok || isRedirect,
90
+ status: response.status,
91
+ detail: location ?? "submitted (archive.today may still be processing — check the site manually)",
92
+ };
93
+ }
94
+
95
+ const PROVIDERS = {
96
+ wayback: { label: "Wayback Machine", submit: submitToWaybackMachine },
97
+ archivetoday: { label: "archive.today", submit: submitToArchiveToday },
98
+ };
99
+
100
+ async function main() {
101
+ const args = parseArgs(process.argv.slice(2));
102
+ const urls = args.urls.length ? args.urls.map((u) => (u.startsWith("http") ? u : `${SITE_URL}${u}`)) : discoverUrls();
103
+ const providerNames = args.provider === "all" ? Object.keys(PROVIDERS) : [args.provider];
104
+
105
+ for (const name of providerNames) {
106
+ if (!PROVIDERS[name]) throw new Error(`Unknown provider "${name}". Expected one of: wayback, archivetoday, all`);
107
+ }
108
+
109
+ console.log(`Submitting ${urls.length} URL(s) to: ${providerNames.map((n) => PROVIDERS[n].label).join(", ")}`);
110
+ if (args.dryRun) {
111
+ for (const url of urls) console.log(`[dry-run] ${url}`);
112
+ return;
113
+ }
114
+
115
+ for (const name of providerNames) {
116
+ const provider = PROVIDERS[name];
117
+ console.log(`\n--- ${provider.label} ---`);
118
+ for (const [index, url] of urls.entries()) {
119
+ try {
120
+ const result = await provider.submit(url);
121
+ const icon = result.ok ? "✔" : "✖";
122
+ console.log(`${icon} [${result.status}] ${url} — ${result.detail}`);
123
+ } catch (err) {
124
+ console.log(`✖ [error] ${url} — ${err.message}`);
125
+ }
126
+ if (index < urls.length - 1) await sleep(DELAY_BETWEEN_REQUESTS_MS);
127
+ }
128
+ }
129
+ }
130
+
131
+ main().catch((err) => {
132
+ console.error(err.message);
133
+ process.exit(1);
134
+ });
src/_data/repoCommits.js CHANGED
@@ -51,14 +51,14 @@ export default async () => {
51
51
  if (sha) writeCache(cacheKey, sha, entries);
52
52
  }
53
53
 
54
- // Only `id`/`data` ever get read off `item.repo` in commit.njk — not the
54
+ // Only `id`/`data`/`version` ever get read off `item.repo` in
55
- // full `repo` object's `files`/`tree`/`commits`/`readme`. Eleventy's
55
+ // commit.njk — not the full `repo` object's `files`/`tree`/`commits`/
56
- // paginator deep-clones each page's data slice for isolation, so
56
+ // `readme`. Eleventy's paginator deep-clones each page's data slice for
57
- // embedding the whole repo (every commit's metadata, every file, the
57
+ // isolation, so embedding the whole repo (every commit's metadata,
58
- // tree, the README) here means that whole structure gets cloned once
58
+ // every file, the tree, the README) here means that whole structure
59
- // per commit page — the actual multiplier behind the OOM, not just the
59
+ // gets cloned once per commit page — the actual multiplier behind the
60
- // diff HTML size.
60
+ // OOM, not just the diff HTML size.
61
- const slimRepo = { id: repo.id, data: repo.data };
61
+ const slimRepo = { id: repo.id, data: repo.data, version: repo.version };
62
62
  for (const entry of entries) pages.push({ repoId: repo.id, repo: slimRepo, ...entry });
63
63
  }
64
64
  return pages;
src/_data/repoFiles.js CHANGED
@@ -52,11 +52,11 @@ export default async () => {
52
52
  }
53
53
 
54
54
  // file.njk/file-history.njk/file-blame.njk only ever read `id`/`data`/
55
- // `tree` off `item.repo` — not `files`/`commits`/`readme`. See the same
55
+ // `tree`/`version` off `item.repo` — not `files`/`commits`/`readme`.
56
- // note in repoCommits.js: Eleventy clones each page's data slice, so
56
+ // See the same note in repoCommits.js: Eleventy clones each page's data
57
- // keeping this slim avoids cloning the repo's full commit/file lists on
57
+ // slice, so keeping this slim avoids cloning the repo's full commit/
58
- // every single file page.
58
+ // file lists on every single file page.
59
- const slimRepo = { id: repo.id, data: repo.data, tree: repo.tree };
59
+ const slimRepo = { id: repo.id, data: repo.data, tree: repo.tree, version: repo.version };
60
60
  for (const entry of entries) pages.push({ repoId: repo.id, repo: slimRepo, ...entry });
61
61
  }
62
62
  return pages;
src/_helpers/gitRepos.js CHANGED
@@ -274,6 +274,54 @@ const readReadme = (repoDir) => {
274
274
  return undefined;
275
275
  };
276
276
 
277
+ const readCargoVersion = (repoDir) => {
278
+ const p = path.join(repoDir, "Cargo.toml");
279
+ if (!fs.existsSync(p)) return undefined;
280
+ return fs.readFileSync(p, "utf8").match(/^\s*version\s*=\s*"([^"]+)"/m)?.[1];
281
+ };
282
+
283
+ const readPackageJsonVersion = (repoDir) => {
284
+ const p = path.join(repoDir, "package.json");
285
+ if (!fs.existsSync(p)) return undefined;
286
+ try {
287
+ return JSON.parse(fs.readFileSync(p, "utf8")).version;
288
+ } catch {
289
+ return undefined;
290
+ }
291
+ };
292
+
293
+ // go.mod has no dedicated version field — Go modules version via git tags,
294
+ // not file content — so the only version signal it ever encodes is the
295
+ // major-version suffix a module path gets once it reaches v2+ (e.g.
296
+ // `module example.com/foo/v3`). That's a major version only, not a full
297
+ // semver, but it's the one real thing there is to surface here.
298
+ const readGoModVersion = (repoDir) => {
299
+ const p = path.join(repoDir, "go.mod");
300
+ if (!fs.existsSync(p)) return undefined;
301
+ const match = fs.readFileSync(p, "utf8").match(/^module\s+\S+\/v(\d+)\s*$/m);
302
+ return match ? `${match[1]}.0.0` : undefined;
303
+ };
304
+
305
+ // pubspec version is `x.y.z` or `x.y.z+buildNumber` (Flutter/Dart) — shown
306
+ // as-is, build number included, since that's the literal file content.
307
+ const readPubspecVersion = (repoDir) => {
308
+ const p = path.join(repoDir, "pubspec.yaml");
309
+ if (!fs.existsSync(p)) return undefined;
310
+ return fs.readFileSync(p, "utf8").match(/^version:\s*(\S+)/m)?.[1];
311
+ };
312
+
313
+ // Checked rarest-manifest-first: a pubspec.yaml or go.mod only exists
314
+ // because the repo IS a Flutter or Go project, but a Cargo.toml or
315
+ // package.json can just as easily be secondary tooling living alongside
316
+ // the real manifest (e.g. only-bible-app is a Flutter app with its own
317
+ // Cargo.toml for a native plugin and a package.json for build scripts —
318
+ // neither's version is the app's version).
319
+ const readVersion = (repoDir) =>
320
+ readPubspecVersion(repoDir) ??
321
+ readGoModVersion(repoDir) ??
322
+ readCargoVersion(repoDir) ??
323
+ readPackageJsonVersion(repoDir);
324
+
277
325
  let cache;
278
326
 
279
327
  export const discoverLocalRepos = () => {
@@ -300,6 +348,7 @@ export const discoverLocalRepos = () => {
300
348
  tree: buildFileTree(files),
301
349
  commits: getCommits(dir),
302
350
  readme: readReadme(dir),
351
+ version: readVersion(dir),
303
352
  };
304
353
  });
305
354
  return cache;
src/_includes/repo-layout.njk CHANGED
@@ -1,8 +1,8 @@
1
- {% macro repoLayout(repo, title, desc, tags, tab) %}
1
+ {% macro repoLayout(repo, title, desc, tags, tab, version) %}
2
2
  {{ iconSpriteDefs() | safe }}
3
3
  <div class="repo">
4
4
  <div class="header">
5
- <h1><span style="view-transition-name:repo-title-{{ title }}">{{ title }}</span></h1>
5
+ <h1><span style="view-transition-name:repo-title-{{ title }}">{{ title }}</span>{% if version %} <span class="repo-version">v{{ version }}</span>{% endif %}</h1>
6
6
  <div class="tags">{% for tag in tags %}<span class="tag">#{{ tag }}</span>{% endfor %}</div>
7
7
  </div>
8
8
  <h3 class="clone-cmd">
src/repos/commit.njk CHANGED
@@ -10,7 +10,7 @@ eleventyComputed:
10
10
  ---
11
11
  {% from "repo-layout.njk" import repoLayout %}
12
12
  <div id="repo-page">
13
- {{ repoLayout(item.repo.id, item.repo.data.title, item.repo.data.description, item.repo.data.tags, "Commits") }}
13
+ {{ repoLayout(item.repo.id, item.repo.data.title, item.repo.data.description, item.repo.data.tags, "Commits", item.repo.version) }}
14
14
  <div id="repo-content">
15
15
  <div class="event">
16
16
  <div>
src/repos/commits.njk CHANGED
@@ -10,7 +10,7 @@ eleventyComputed:
10
10
  ---
11
11
  {% from "repo-layout.njk" import repoLayout %}
12
12
  <div id="repo-page">
13
- {{ repoLayout(repo.id, repo.data.title, repo.data.description, repo.data.tags, "Commits") }}
13
+ {{ repoLayout(repo.id, repo.data.title, repo.data.description, repo.data.tags, "Commits", repo.version) }}
14
14
  <div id="repo-content">
15
15
  {% if repo.commits.length %}
16
16
  {% for commit in repo.commits %}
src/repos/file-blame.njk CHANGED
@@ -11,7 +11,7 @@ eleventyComputed:
11
11
  {% from "repo-layout.njk" import repoLayout %}
12
12
  {% from "file-layout.njk" import fileLayout %}
13
13
  <div id="repo-page">
14
- {{ repoLayout(item.repo.id, item.repo.data.title, item.repo.data.description, item.repo.data.tags, "Files") }}
14
+ {{ repoLayout(item.repo.id, item.repo.data.title, item.repo.data.description, item.repo.data.tags, "Files", item.repo.version) }}
15
15
  <div id="repo-content">
16
16
  <div class="file-explorer">
17
17
  <aside class="file-explorer-tree">
src/repos/file-history.njk CHANGED
@@ -11,7 +11,7 @@ eleventyComputed:
11
11
  {% from "repo-layout.njk" import repoLayout %}
12
12
  {% from "file-layout.njk" import fileLayout %}
13
13
  <div id="repo-page">
14
- {{ repoLayout(item.repo.id, item.repo.data.title, item.repo.data.description, item.repo.data.tags, "Files") }}
14
+ {{ repoLayout(item.repo.id, item.repo.data.title, item.repo.data.description, item.repo.data.tags, "Files", item.repo.version) }}
15
15
  <div id="repo-content">
16
16
  <div class="file-explorer">
17
17
  <aside class="file-explorer-tree">
src/repos/file.njk CHANGED
@@ -11,7 +11,7 @@ eleventyComputed:
11
11
  {% from "repo-layout.njk" import repoLayout %}
12
12
  {% from "file-layout.njk" import fileLayout, fileContent %}
13
13
  <div id="repo-page">
14
- {{ repoLayout(item.repo.id, item.repo.data.title, item.repo.data.description, item.repo.data.tags, "Files") }}
14
+ {{ repoLayout(item.repo.id, item.repo.data.title, item.repo.data.description, item.repo.data.tags, "Files", item.repo.version) }}
15
15
  <div id="repo-content">
16
16
  <div class="file-explorer">
17
17
  <aside class="file-explorer-tree">
src/repos/files-index.njk CHANGED
@@ -13,7 +13,7 @@ eleventyComputed:
13
13
  {% set defaultEntry = repoFiles | filterByRepo(repo.id) | defaultFileEntry %}
14
14
  {% set activePath = defaultEntry.file.path if defaultEntry else "" %}
15
15
  <div id="repo-page">
16
- {{ repoLayout(repo.id, repo.data.title, repo.data.description, repo.data.tags, "Files") }}
16
+ {{ repoLayout(repo.id, repo.data.title, repo.data.description, repo.data.tags, "Files", repo.version) }}
17
17
  <div id="repo-content">
18
18
  <div class="file-explorer">
19
19
  <aside class="file-explorer-tree">
src/repos/repo.njk CHANGED
@@ -11,7 +11,7 @@ eleventyComputed:
11
11
  ---
12
12
  {% from "repo-layout.njk" import repoLayout %}
13
13
  <div id="repo-page">
14
- {{ repoLayout(repo.id, repo.data.title, repo.data.description, repo.data.tags, "Readme") }}
14
+ {{ repoLayout(repo.id, repo.data.title, repo.data.description, repo.data.tags, "Readme", repo.version) }}
15
15
  <div id="repo-content">
16
16
  {% if repo.readmeHtml %}
17
17
  <article class="markdown-body">{{ repo.readmeHtml | safe }}</article>
tests/helpers/static-server.js ADDED
@@ -0,0 +1,76 @@
1
+ import { createServer } from "node:http";
2
+ import { readFile, stat } from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ const MIME_TYPES = {
6
+ ".html": "text/html; charset=utf-8",
7
+ ".css": "text/css; charset=utf-8",
8
+ ".js": "text/javascript; charset=utf-8",
9
+ ".json": "application/json; charset=utf-8",
10
+ ".svg": "image/svg+xml",
11
+ ".png": "image/png",
12
+ ".jpg": "image/jpeg",
13
+ ".jpeg": "image/jpeg",
14
+ ".ico": "image/x-icon",
15
+ ".xml": "application/xml; charset=utf-8",
16
+ ".txt": "text/plain; charset=utf-8",
17
+ ".woff2": "font/woff2",
18
+ ".pdf": "application/pdf",
19
+ };
20
+
21
+ async function resolveFile(rootDir, urlPath) {
22
+ const decoded = decodeURIComponent(urlPath.split("?")[0]);
23
+ const safeSuffix = path.normalize(decoded).replace(/^(\.\.[/\\])+/, "");
24
+ const requestedPath = path.join(rootDir, safeSuffix);
25
+
26
+ const candidates = requestedPath.endsWith("/")
27
+ ? [path.join(requestedPath, "index.html")]
28
+ : [requestedPath, path.join(requestedPath, "index.html"), `${requestedPath}.html`];
29
+
30
+ for (const candidate of candidates) {
31
+ try {
32
+ const info = await stat(candidate);
33
+ if (info.isFile()) return candidate;
34
+ } catch {
35
+ // try next candidate
36
+ }
37
+ }
38
+ return null;
39
+ }
40
+
41
+ /**
42
+ * Serves a pre-built static site directory (e.g. Eleventy's `dist/`) over HTTP,
43
+ * falling back to `404.html` so tests see the same not-found page as production.
44
+ */
45
+ export async function startStaticServer(rootDir, { notFoundFile = "404.html" } = {}) {
46
+ const server = createServer(async (req, res) => {
47
+ try {
48
+ const filePath = await resolveFile(rootDir, req.url ?? "/");
49
+ if (filePath) {
50
+ const body = await readFile(filePath);
51
+ res.writeHead(200, { "content-type": MIME_TYPES[path.extname(filePath)] ?? "application/octet-stream" });
52
+ res.end(body);
53
+ return;
54
+ }
55
+ const fallback = path.join(rootDir, notFoundFile);
56
+ try {
57
+ const body = await readFile(fallback);
58
+ res.writeHead(404, { "content-type": "text/html; charset=utf-8" });
59
+ res.end(body);
60
+ } catch {
61
+ res.writeHead(404, { "content-type": "text/plain" });
62
+ res.end("Not found");
63
+ }
64
+ } catch (err) {
65
+ res.writeHead(500, { "content-type": "text/plain" });
66
+ res.end(String(err));
67
+ }
68
+ });
69
+
70
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
71
+ const { port } = server.address();
72
+ return {
73
+ url: `http://127.0.0.1:${port}`,
74
+ close: () => new Promise((resolve) => server.close(resolve)),
75
+ };
76
+ }
tests/pages.test.js ADDED
@@ -0,0 +1,146 @@
1
+ import { after, before, describe, test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { chromium } from "playwright";
6
+ import { startStaticServer } from "./helpers/static-server.js";
7
+
8
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
+ const distDir = path.join(__dirname, "..", "dist");
10
+
11
+ let server;
12
+ let browser;
13
+ let page;
14
+ const consoleErrors = [];
15
+
16
+ before(async () => {
17
+ server = await startStaticServer(distDir);
18
+ browser = await chromium.launch();
19
+ page = await browser.newPage();
20
+ page.on("console", (msg) => {
21
+ if (msg.type() === "error") consoleErrors.push(`[${page.url()}] ${msg.text()}`);
22
+ });
23
+ page.on("pageerror", (err) => {
24
+ consoleErrors.push(`[${page.url()}] ${err.message}`);
25
+ });
26
+ });
27
+
28
+ after(async () => {
29
+ await browser?.close();
30
+ await server?.close();
31
+ });
32
+
33
+ async function goto(urlPath) {
34
+ const response = await page.goto(`${server.url}${urlPath}`, { waitUntil: "domcontentloaded" });
35
+ return response;
36
+ }
37
+
38
+ describe("static pages", () => {
39
+ test("home page renders", async () => {
40
+ const response = await goto("/");
41
+ assert.equal(response.status(), 200);
42
+ await assert.doesNotReject(page.locator("h1.title").waitFor());
43
+ assert.match(await page.title(), /pyrossh/);
44
+ });
45
+
46
+ test("cv page renders", async () => {
47
+ const response = await goto("/cv/");
48
+ assert.equal(response.status(), 200);
49
+ await assert.doesNotReject(page.locator("h1").waitFor());
50
+ assert.equal(await page.locator("img[alt='Resume']").count(), 1);
51
+ });
52
+
53
+ test("posts index lists posts", async () => {
54
+ const response = await goto("/posts/");
55
+ assert.equal(response.status(), 200);
56
+ const links = page.locator("main a[href^='/posts/']");
57
+ assert.ok((await links.count()) > 0, "expected at least one post link");
58
+ });
59
+
60
+ test("a post page renders", async () => {
61
+ const response = await goto("/posts/react-powertools-swr/");
62
+ assert.equal(response.status(), 200);
63
+ assert.match(await page.title(), /React Powertools/);
64
+ });
65
+
66
+ test("only-bible-app pages render", async () => {
67
+ for (const url of ["/only-bible-app/", "/only-bible-app/privacy-policy/", "/only-bible-app/terms-and-conditions/"]) {
68
+ const response = await goto(url);
69
+ assert.equal(response.status(), 200, `expected 200 for ${url}`);
70
+ }
71
+ });
72
+
73
+ test("robots.txt and rss.xml are served", async () => {
74
+ const robots = await goto("/robots.txt");
75
+ assert.equal(robots.status(), 200);
76
+ assert.match(await page.content(), /User-agent/);
77
+
78
+ const rss = await goto("/rss.xml");
79
+ assert.equal(rss.status(), 200);
80
+ });
81
+
82
+ test("unknown route returns the 404 page", async () => {
83
+ const response = await goto("/this-page-does-not-exist/");
84
+ assert.equal(response.status(), 404);
85
+ assert.match(await page.locator("h1").textContent(), /404/);
86
+ });
87
+ });
88
+
89
+ describe("repo pages", () => {
90
+ const repoId = "website";
91
+
92
+ test("repo readme page renders", async () => {
93
+ const response = await goto(`/repos/${repoId}/`);
94
+ assert.equal(response.status(), 200);
95
+ await assert.doesNotReject(page.locator(".repo .header h1").waitFor());
96
+ assert.equal(await page.locator(".nav a.active-tab").textContent(), "Readme");
97
+ });
98
+
99
+ test("repo commits page lists commits", async () => {
100
+ const response = await goto(`/repos/${repoId}/commits/`);
101
+ assert.equal(response.status(), 200);
102
+ const commitLinks = page.locator(`a[href^='/repos/${repoId}/commits/']`);
103
+ assert.ok((await commitLinks.count()) > 0, "expected at least one commit link");
104
+ });
105
+
106
+ test("a single commit page renders", async () => {
107
+ await goto(`/repos/${repoId}/commits/`);
108
+ const firstCommitHref = await page.locator(`a[href^='/repos/${repoId}/commits/']`).first().getAttribute("href");
109
+ const response = await goto(firstCommitHref);
110
+ assert.equal(response.status(), 200);
111
+ assert.equal(await page.locator(".nav a.active-tab").textContent(), "Commits");
112
+ });
113
+
114
+ test("repo files index renders the tree and a default file", async () => {
115
+ const response = await goto(`/repos/${repoId}/files/`);
116
+ assert.equal(response.status(), 200);
117
+ assert.ok((await page.locator(".file-explorer-tree").count()) > 0);
118
+ });
119
+
120
+ test("a specific file, its history and blame views render", async () => {
121
+ const filePath = "eleventy.config.js";
122
+ const fileRes = await goto(`/repos/${repoId}/files/${filePath}/`);
123
+ assert.equal(fileRes.status(), 200);
124
+
125
+ const historyRes = await goto(`/repos/${repoId}/files/${filePath}/history/`);
126
+ assert.equal(historyRes.status(), 200);
127
+
128
+ const blameRes = await goto(`/repos/${repoId}/files/${filePath}/blame/`);
129
+ assert.equal(blameRes.status(), 200);
130
+ });
131
+
132
+ test("repo nav tabs swap content via htmx without a full reload", async () => {
133
+ await goto(`/repos/${repoId}/`);
134
+ await page.click(".nav a[href='/repos/website/files/']");
135
+ await page.waitForURL(`${server.url}/repos/${repoId}/files/`);
136
+ assert.equal(await page.locator(".nav a.active-tab").textContent(), "Code");
137
+ });
138
+ });
139
+
140
+ test("no unexpected console errors were logged across visited pages", () => {
141
+ // The deliberate 404 test above triggers a browser-level "failed to load
142
+ // resource" console entry for its own navigation request — that's the
143
+ // behavior under test there, not a bug, so it's excluded here.
144
+ const unexpected = consoleErrors.filter((msg) => !msg.includes("this-page-does-not-exist"));
145
+ assert.deepEqual(unexpected, []);
146
+ });