website
git clone https://git.pyrossh.dev/website
木 Personal website of pyrossh. Built with astrojs, shiki, vite.
09c6a56
— pyrossh
2026-08-06T09:59:15+05:30
moar improvements
- assets/css/repos.css +8 -0
- assets/css/shared.css +3 -3
- package-lock.json +49 -1
- package.json +6 -1
- scripts/submit-to-archives.js +134 -0
- src/_data/repoCommits.js +8 -8
- src/_data/repoFiles.js +5 -5
- src/_helpers/gitRepos.js +49 -0
- src/_includes/repo-layout.njk +2 -2
- src/repos/commit.njk +1 -1
- src/repos/commits.njk +1 -1
- src/repos/file-blame.njk +1 -1
- src/repos/file-history.njk +1 -1
- src/repos/file.njk +1 -1
- src/repos/files-index.njk +1 -1
- src/repos/repo.njk +1 -1
- tests/helpers/static-server.js +76 -0
- tests/pages.test.js +146 -0
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:
|
|
194
|
+
max-width: 72ch;
|
|
195
195
|
margin-right: auto;
|
|
196
196
|
margin-left: auto;
|
|
197
|
-
padding-left:
|
|
197
|
+
padding-left: 1rem;
|
|
198
|
-
padding-right:
|
|
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
|
|
54
|
+
// Only `id`/`data`/`version` ever get read off `item.repo` in
|
|
55
|
-
// full `repo` object's `files`/`tree`/`commits`/
|
|
55
|
+
// commit.njk — not the full `repo` object's `files`/`tree`/`commits`/
|
|
56
|
-
// paginator deep-clones each page's data slice for
|
|
56
|
+
// `readme`. Eleventy's paginator deep-clones each page's data slice for
|
|
57
|
-
// embedding the whole repo (every commit's metadata,
|
|
57
|
+
// isolation, so embedding the whole repo (every commit's metadata,
|
|
58
|
-
// tree, the README) here means that whole structure
|
|
58
|
+
// every file, the tree, the README) here means that whole structure
|
|
59
|
-
// per commit page — the actual multiplier behind 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`.
|
|
55
|
+
// `tree`/`version` off `item.repo` — not `files`/`commits`/`readme`.
|
|
56
|
-
// note in repoCommits.js: Eleventy clones each page's data
|
|
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/
|
|
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
|
+
});
|