website
git clone https://git.pyrossh.dev/website
木 Personal website of pyrossh. Built with astrojs, shiki, vite.
f547dda
— pyrossh
2026-11-05T19:42:04+05:30
Remove file history/blame pages and share the file tree per repo
- README.md +3 -3
- assets/client.js +19 -3
- assets/css/repos.css +9 -81
- eleventy.config.js +4 -10
- src/_data/repoFiles.js +9 -16
- src/_helpers/dataCache.js +1 -1
- src/_helpers/gitRepos.js +1 -60
- src/_includes/file-layout.njk +1 -9
- src/_includes/file-tree.njk +21 -4
- src/repos/file-blame.njk +0 -47
- src/repos/file-history.njk +0 -44
- src/repos/file.njk +3 -5
- src/repos/files-index.njk +3 -5
- src/repos/tree.njk +21 -0
- tests/pages.test.js +48 -14
README.md
CHANGED
|
@@ -13,7 +13,7 @@ Personal website, built as a static site with [Eleventy](https://www.11ty.dev) a
|
|
|
13
13
|
│ ├── _includes/ layouts and partials
|
|
14
14
|
│ ├── components/ Elena web components
|
|
15
15
|
│ ├── posts/ blog post markdown
|
|
16
|
-
│ ├── repos/ paginated templates for repo readme/files/commits
|
|
16
|
+
│ ├── repos/ paginated templates for repo readme/files/commits
|
|
17
17
|
│ └── *.njk top-level pages
|
|
18
18
|
├── eleventy.config.js
|
|
19
19
|
└── package.json
|
|
@@ -24,8 +24,8 @@ Personal website, built as a static site with [Eleventy](https://www.11ty.dev) a
|
|
|
24
24
|
Pages under `/repos/:id` are generated entirely from local git checkouts that live as
|
|
25
25
|
**sibling directories of this project** — e.g. `../config`, `../rust-embed` next to
|
|
26
26
|
`website/` — not inside this repo. No API calls, no bucket access: README, file
|
|
27
|
-
tree/content, commit list, per-commit diffs
|
|
27
|
+
tree/content, commit list, and per-commit diffs are all read straight from git
|
|
28
|
-
|
|
28
|
+
(`git ls-tree`, `git log`, `git show`) at build time.
|
|
29
29
|
|
|
30
30
|
A repo listed in `src/_data/site.js` only gets pages if a matching directory exists next to
|
|
31
31
|
this project *and* is a git repo in its own right (has its own `.git`, not nested inside
|
assets/client.js
CHANGED
|
@@ -19,12 +19,27 @@ document.getElementById("theme-change").addEventListener("click", () => {
|
|
|
19
19
|
localStorage.setItem("theme", next);
|
|
20
20
|
});
|
|
21
21
|
|
|
22
|
+
// Every file page in a repo loads the same tree (repos/tree.njk), so the tree
|
|
23
|
+
// can't arrive with its active file already marked. Mark it once it's swapped
|
|
24
|
+
// in, opening the folders above it so the rest of the tree stays collapsed.
|
|
25
|
+
document.addEventListener("htmx:afterSwap", (e) => {
|
|
26
|
+
const tree = e.detail.target;
|
|
27
|
+
if (!tree.matches(".file-explorer-tree") || !tree.dataset.activeHref) return;
|
|
28
|
+
const link = tree.querySelector(`a[href="${CSS.escape(tree.dataset.activeHref)}"]`);
|
|
29
|
+
if (!link) return;
|
|
30
|
+
link.classList.add("active-file");
|
|
31
|
+
for (let dir = link.closest("details"); dir; dir = dir.parentElement.closest("details")) {
|
|
32
|
+
dir.open = true;
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
22
36
|
// .file-explorer-tree lives outside #file-pane (see the comment on
|
|
23
37
|
// .file-explorer in repos.css) so htmx swaps of #file-pane never re-render
|
|
24
38
|
// it — clicking a file never rebuilds the tree. That's good for keeping
|
|
25
|
-
// scroll position/expanded folders, but it also means
|
|
39
|
+
// scroll position/expanded folders, but it also means .active-file never
|
|
40
|
+
// moves on its own. Move it here, synchronously on click, instead of waiting
|
|
26
|
-
//
|
|
41
|
+
// on htmx's response. data-active-href moves with it so a tree restored from
|
|
27
|
-
//
|
|
42
|
+
// htmx's history cache and reloaded marks this file, not the original one.
|
|
28
43
|
document.addEventListener("click", (e) => {
|
|
29
44
|
const link = e.target.closest(".file-explorer-tree a");
|
|
30
45
|
if (!link) return;
|
|
@@ -32,6 +47,7 @@ document.addEventListener("click", (e) => {
|
|
|
32
47
|
const current = tree.querySelector("a.active-file");
|
|
33
48
|
if (current) current.classList.remove("active-file");
|
|
34
49
|
link.classList.add("active-file");
|
|
50
|
+
tree.dataset.activeHref = link.getAttribute("href");
|
|
35
51
|
});
|
|
36
52
|
|
|
37
53
|
// Copies the git clone command shown in the repo header (see .clone-cmd in
|
assets/css/repos.css
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/* Repo browsing pages (readme/files/commits
|
|
1
|
+
/* Repo browsing pages (readme/files/commits). Restored from the
|
|
2
2
|
original Astro-era RepoLayout/FileLayout/Commit/RecursiveList components
|
|
3
3
|
(see git history at 7325531^), with the tab nav restyled as a bordered
|
|
4
4
|
tab bar and a couple of small crates.io-inspired touches (clone command
|
|
@@ -8,15 +8,15 @@
|
|
|
8
8
|
values, so nothing reads as randomly bigger/smaller than its neighbour:
|
|
9
9
|
0.85rem — small badges (tags, pull-right meta, file-size hints)
|
|
10
10
|
0.9rem — code/monospace content, nav links and sub-headings alike
|
|
11
|
-
(pre/code-view, file tree,
|
|
11
|
+
(pre/code-view, file tree, file title)
|
|
12
12
|
0.95rem — box body text (.repo, .event)
|
|
13
13
|
var(--fs-repo-title) — page-level heading (.repo h1), fluid 1.3–1.6rem
|
|
14
14
|
All of them are rem-based, not em, so nesting depth can't change the
|
|
15
|
-
rendered size — that's what made the old code-view
|
|
15
|
+
rendered size — that's what made the old code-view font sizes
|
|
16
16
|
inconsistent (em compounds with whatever ancestor happens to wrap it).
|
|
17
17
|
|
|
18
18
|
Everything below uses plain CSS nesting rather than @scope: .repo,
|
|
19
|
-
.file-layout, .event
|
|
19
|
+
.file-layout, .event and .file-explorer are all real,
|
|
20
20
|
always-present classes/tags on these pages (none of them are wrapped
|
|
21
21
|
in a custom element anymore), so nesting under them scopes their
|
|
22
22
|
descendants exactly the way @scope would, without needing a scope root
|
|
@@ -184,7 +184,7 @@
|
|
|
184
184
|
}
|
|
185
185
|
|
|
186
186
|
/* Commit list / commit detail header (Commit.astro) — repos/commit.njk,
|
|
187
|
-
commits.njk
|
|
187
|
+
commits.njk. pre.commit is nested here too since
|
|
188
188
|
it's always .event's child, never used alone. */
|
|
189
189
|
.event {
|
|
190
190
|
background-color: var(--color-box-bg);
|
|
@@ -220,9 +220,8 @@
|
|
|
220
220
|
pane, each its own rounded box with a gap between them rather than a
|
|
221
221
|
single shared border — the gap is what visually separates them, so
|
|
222
222
|
they don't also need to differ in colour. The tree lives outside
|
|
223
|
-
#file-pane, so htmx swaps of #file-pane (clicking a file)
|
|
223
|
+
#file-pane, so htmx swaps of #file-pane (clicking a file) never touch
|
|
224
|
-
#file-content (Contents/History/Blame) never touch the sidebar — it
|
|
225
|
-
just stays where it is, already expanded, no reload.
|
|
224
|
+
the sidebar — it just stays where it is, already expanded, no reload.
|
|
226
225
|
|
|
227
226
|
Fixed-height explorer: both panes stretch to the same height and scroll
|
|
228
227
|
independently, so scrolling a long file never scrolls the tree out of
|
|
@@ -395,9 +394,8 @@
|
|
|
395
394
|
}
|
|
396
395
|
}
|
|
397
396
|
|
|
398
|
-
/* File view header — a compact icon+filename strip
|
|
397
|
+
/* File view header — a compact icon+filename strip, sitting as the card's
|
|
399
|
-
Contents/History/Blame tabs, sitting as the card's title bar rather
|
|
400
|
-
than a full-width heading. */
|
|
398
|
+
title bar rather than a full-width heading. */
|
|
401
399
|
.file-layout {
|
|
402
400
|
display: flex;
|
|
403
401
|
align-items: center;
|
|
@@ -412,11 +410,6 @@
|
|
|
412
410
|
display: flex;
|
|
413
411
|
align-items: center;
|
|
414
412
|
gap: 0.4rem;
|
|
415
|
-
/* flex:1 (not the default 0 1 auto) so it's the one that gives up
|
|
416
|
-
space when the row is tight — without this, a long filename could
|
|
417
|
-
force the row to wrap .nav onto its own line instead of shrinking,
|
|
418
|
-
since the wrap decision is made off each item's unshrunk
|
|
419
|
-
hypothetical width, not its post-shrink width. */
|
|
420
413
|
flex: 1 1 auto;
|
|
421
414
|
min-width: 0;
|
|
422
415
|
font-size: 0.9rem;
|
|
@@ -435,71 +428,6 @@
|
|
|
435
428
|
white-space: nowrap;
|
|
436
429
|
}
|
|
437
430
|
}
|
|
438
|
-
|
|
439
|
-
.nav {
|
|
440
|
-
display: flex;
|
|
441
|
-
align-items: center;
|
|
442
|
-
gap: 0.25rem;
|
|
443
|
-
flex-shrink: 0;
|
|
444
|
-
|
|
445
|
-
div:not(:has(a)) {
|
|
446
|
-
display: none;
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
a {
|
|
450
|
-
font-size: 0.9rem;
|
|
451
|
-
font-weight: 500;
|
|
452
|
-
padding: 0.3rem 0.1rem;
|
|
453
|
-
margin-right: 0.75rem;
|
|
454
|
-
cursor: pointer;
|
|
455
|
-
color: var(--color-text);
|
|
456
|
-
border-bottom: 2px solid transparent;
|
|
457
|
-
|
|
458
|
-
&:hover {
|
|
459
|
-
color: var(--color-link);
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
&[aria-current="true"] {
|
|
463
|
-
color: var(--color-link);
|
|
464
|
-
border-bottom-color: var(--color-link);
|
|
465
|
-
}
|
|
466
|
-
}
|
|
467
|
-
}
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
/* Blame table — repos/file-blame.njk. */
|
|
471
|
-
table.blame {
|
|
472
|
-
width: 100%;
|
|
473
|
-
border-collapse: collapse;
|
|
474
|
-
font-size: 0.9rem;
|
|
475
|
-
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
476
|
-
background: var(--color-pre-bg);
|
|
477
|
-
border-radius: 4px;
|
|
478
|
-
overflow: hidden;
|
|
479
|
-
|
|
480
|
-
td {
|
|
481
|
-
vertical-align: top;
|
|
482
|
-
padding: 0.1rem 0.5rem;
|
|
483
|
-
border-bottom: 1px solid var(--color-box-bg);
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
.blame-meta {
|
|
487
|
-
white-space: nowrap;
|
|
488
|
-
color: var(--color-link);
|
|
489
|
-
width: 1%;
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
.blame-line-no {
|
|
493
|
-
white-space: nowrap;
|
|
494
|
-
opacity: 0.5;
|
|
495
|
-
text-align: right;
|
|
496
|
-
width: 1%;
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
.blame-content pre {
|
|
500
|
-
margin: 0;
|
|
501
|
-
white-space: pre-wrap;
|
|
502
|
-
}
|
|
503
431
|
}
|
|
504
432
|
|
|
505
433
|
/* diff2html ships its own light/dark token sets as plain CSS custom
|
eleventy.config.js
CHANGED
|
@@ -49,9 +49,10 @@ export default function (eleventyConfig) {
|
|
|
49
49
|
"node_modules/diff2html/bundles/css/diff2html.min.css": "assets/css/diff2html.min.css",
|
|
50
50
|
});
|
|
51
51
|
|
|
52
|
-
// htmx powers no-full-page-reload
|
|
52
|
+
// htmx powers no-full-page-reload navigation on repo pages (Readme/Commits/
|
|
53
|
-
//
|
|
53
|
+
// Files tabs, the file tree) — hx-select pulls #repo-page or #file-pane out
|
|
54
|
-
//
|
|
54
|
+
// of the fetched static page and swaps it in place. It also loads each
|
|
55
|
+
// repo's shared file tree into its file pages (see repos/tree.njk).
|
|
55
56
|
// Vendored locally rather than a CDN <script> so the build has no
|
|
56
57
|
// runtime network dependency.
|
|
57
58
|
eleventyConfig.addPassthroughCopy({
|
|
@@ -88,13 +89,6 @@ export default function (eleventyConfig) {
|
|
|
88
89
|
if (!entries.length) return null;
|
|
89
90
|
return entries.find((e) => e.file.path.toLowerCase() === "readme.md") ?? entries[entries.length - 1];
|
|
90
91
|
});
|
|
91
|
-
// A folder should start open only when the active file lives inside it —
|
|
92
|
-
// otherwise every directory in the tree defaults to expanded, which on a
|
|
93
|
-
// repo of any size is just a wall of open folders with nothing collapsed.
|
|
94
|
-
eleventyConfig.addFilter(
|
|
95
|
-
"isAncestorDir",
|
|
96
|
-
(dirPath, activePath) => !!activePath && (activePath === dirPath || activePath.startsWith(`${dirPath}/`)),
|
|
97
|
-
);
|
|
98
92
|
eleventyConfig.addFilter("formatBytes", (bytes) => {
|
|
99
93
|
if (!bytes) return "0B";
|
|
100
94
|
const units = ["B", "KB", "MB", "GB"];
|
src/_data/repoFiles.js
CHANGED
|
@@ -6,8 +6,6 @@ import {
|
|
|
6
6
|
IMAGE_EXTENSIONS,
|
|
7
7
|
LARGE_FILE_SIZE,
|
|
8
8
|
discoverLocalRepos,
|
|
9
|
-
getBlame,
|
|
10
|
-
getFileHistory,
|
|
11
9
|
getHeadSha,
|
|
12
10
|
isDirty,
|
|
13
11
|
toDataUri,
|
|
@@ -36,23 +34,18 @@ export default async () => {
|
|
|
36
34
|
|
|
37
35
|
let entries = cacheable ? readCache(cacheKey, sha) : null;
|
|
38
36
|
if (!entries) {
|
|
39
|
-
entries = await mapPool(repo.files, CONCURRENCY, async (file) => {
|
|
37
|
+
entries = await mapPool(repo.files, CONCURRENCY, async (file) => ({
|
|
38
|
+
file,
|
|
40
|
-
|
|
39
|
+
render: renderFile(repo.dir, file),
|
|
41
|
-
const [history, blame] = await Promise.all([
|
|
42
|
-
getFileHistory(repo.dir, file.path),
|
|
43
|
-
render.kind === "text" ? getBlame(repo.dir, file.path) : [],
|
|
44
|
-
]);
|
|
45
|
-
return { file, render, history, blame };
|
|
46
|
-
});
|
|
40
|
+
}));
|
|
47
41
|
if (cacheable) writeCache(cacheKey, sha, entries);
|
|
48
42
|
}
|
|
49
43
|
|
|
50
|
-
// file.njk
|
|
44
|
+
// file.njk only ever reads `id`/`data`/`version` off `item.repo` — not
|
|
51
|
-
// `
|
|
45
|
+
// `files`/`commits`/`readme`/`tree`. See the same note in repoCommits.js:
|
|
52
|
-
//
|
|
46
|
+
// Eleventy clones each page's data slice, so keeping this slim avoids
|
|
53
|
-
// slice, so keeping this slim avoids cloning the repo's full commit/
|
|
54
|
-
// file lists on every single file page.
|
|
47
|
+
// cloning the repo's full commit/file lists on every single file page.
|
|
55
|
-
const slimRepo = { id: repo.id, data: repo.data,
|
|
48
|
+
const slimRepo = { id: repo.id, data: repo.data, version: repo.version };
|
|
56
49
|
for (const entry of entries) pages.push({ repoId: repo.id, repo: slimRepo, ...entry });
|
|
57
50
|
}
|
|
58
51
|
return pages;
|
src/_helpers/dataCache.js
CHANGED
|
@@ -9,7 +9,7 @@ const cachePath = (key) => path.join(CACHE_DIR, `${key}.json`);
|
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* Disk cache for the expensive per-commit/per-file git data (diffs,
|
|
12
|
-
*
|
|
12
|
+
* syntax highlighting) computed in repoCommits.js/
|
|
13
13
|
* repoFiles.js. Keyed by the repo's current HEAD sha rather than by
|
|
14
14
|
* content hash — cheap to check (one `git rev-parse` per repo) and
|
|
15
15
|
* correct as long as callers only read the cache for a clean working
|
src/_helpers/gitRepos.js
CHANGED
|
@@ -174,23 +174,6 @@ const getCommits = (repoDir) => {
|
|
|
174
174
|
}
|
|
175
175
|
};
|
|
176
176
|
|
|
177
|
-
export const getFileHistory = async (repoDir, filePath) => {
|
|
178
|
-
if (!isOwnGitRepo(repoDir)) return [];
|
|
179
|
-
try {
|
|
180
|
-
const out = await gitAsync(repoDir, [
|
|
181
|
-
"log",
|
|
182
|
-
"--follow",
|
|
183
|
-
"--date=iso-strict",
|
|
184
|
-
`--pretty=format:%H${SEP}%an${SEP}%ad${SEP}%s`,
|
|
185
|
-
"--",
|
|
186
|
-
filePath,
|
|
187
|
-
]);
|
|
188
|
-
return parseLogFormat(out);
|
|
189
|
-
} catch {
|
|
190
|
-
return [];
|
|
191
|
-
}
|
|
192
|
-
};
|
|
193
|
-
|
|
194
177
|
export const getCommitDiff = async (repoDir, hash) => {
|
|
195
178
|
if (!isOwnGitRepo(repoDir)) return "";
|
|
196
179
|
try {
|
|
@@ -200,50 +183,8 @@ export const getCommitDiff = async (repoDir, hash) => {
|
|
|
200
183
|
}
|
|
201
184
|
};
|
|
202
185
|
|
|
203
|
-
export const getBlame = async (repoDir, filePath) => {
|
|
204
|
-
if (!isOwnGitRepo(repoDir)) return [];
|
|
205
|
-
try {
|
|
206
|
-
const out = await gitAsync(repoDir, ["blame", "--porcelain", "--", filePath]);
|
|
207
|
-
const lines = out.split("\n");
|
|
208
|
-
const commitInfo = new Map();
|
|
209
|
-
const result = [];
|
|
210
|
-
let i = 0;
|
|
211
|
-
while (i < lines.length) {
|
|
212
|
-
const header = lines[i]?.match(/^([0-9a-f]{40,64}) (\d+) (\d+)(?: (\d+))?$/);
|
|
213
|
-
if (!header) {
|
|
214
|
-
i++;
|
|
215
|
-
continue;
|
|
216
|
-
}
|
|
217
|
-
const hash = header[1];
|
|
218
|
-
const finalLine = parseInt(header[3], 10);
|
|
219
|
-
if (!commitInfo.has(hash)) commitInfo.set(hash, {});
|
|
220
|
-
const info = commitInfo.get(hash);
|
|
221
|
-
i++;
|
|
222
|
-
while (i < lines.length && !lines[i].startsWith("\t")) {
|
|
223
|
-
const line = lines[i];
|
|
224
|
-
if (line.startsWith("author ")) info.author = line.slice(7);
|
|
225
|
-
else if (line.startsWith("author-time ")) info.time = line.slice(12);
|
|
226
|
-
i++;
|
|
227
|
-
}
|
|
228
|
-
const content = lines[i] !== undefined ? lines[i].slice(1) : "";
|
|
229
|
-
result.push({
|
|
230
|
-
hash,
|
|
231
|
-
shortHash: hash.slice(0, 7),
|
|
232
|
-
author: info.author,
|
|
233
|
-
date: info.time ? new Date(parseInt(info.time, 10) * 1000).toISOString() : "",
|
|
234
|
-
line: finalLine,
|
|
235
|
-
content,
|
|
236
|
-
});
|
|
237
|
-
i++;
|
|
238
|
-
}
|
|
239
|
-
return result;
|
|
240
|
-
} catch {
|
|
241
|
-
return [];
|
|
242
|
-
}
|
|
243
|
-
};
|
|
244
|
-
|
|
245
186
|
// Cache key material for dataCache.js: the per-commit/per-file data
|
|
246
|
-
// (diffs,
|
|
187
|
+
// (diffs, highlighting) computed in repoCommits.js/
|
|
247
188
|
// repoFiles.js only depends on the repo's committed state, so it's safe
|
|
248
189
|
// to skip recomputing it whenever HEAD hasn't moved — but only when the
|
|
249
190
|
// working tree is also clean, since file content there is read straight
|
src/_includes/file-layout.njk
CHANGED
|
@@ -1,14 +1,6 @@
|
|
|
1
|
-
{% macro fileLayout(
|
|
1
|
+
{% macro fileLayout(filename, ext) %}
|
|
2
|
-
{% set base = "/repos/" + repo + "/files/" + filepath %}
|
|
3
2
|
<div class="file-layout">
|
|
4
3
|
<div class="title">{{ filename | fileIcon(ext) | safe }}<span>{{ filename }}</span></div>
|
|
5
|
-
<div class="nav">
|
|
6
|
-
<div><a href="{{ base }}/" hx-get="{{ base }}/" hx-select="#file-pane" hx-target="#file-pane" hx-swap="outerHTML" hx-push-url="true" aria-current="{% if tab == 'Contents' %}true{% else %}false{% endif %}">Contents</a></div>
|
|
7
|
-
<div>|</div>
|
|
8
|
-
<div><a href="{{ base }}/history/" hx-get="{{ base }}/history/" hx-select="#file-pane" hx-target="#file-pane" hx-swap="outerHTML" hx-push-url="true" aria-current="{% if tab == 'History' %}true{% else %}false{% endif %}">History</a></div>
|
|
9
|
-
<div>|</div>
|
|
10
|
-
<div><a href="{{ base }}/blame/" hx-get="{{ base }}/blame/" hx-select="#file-pane" hx-target="#file-pane" hx-swap="outerHTML" hx-push-url="true" aria-current="{% if tab == 'Blame' %}true{% else %}false{% endif %}">Blame</a></div>
|
|
11
|
-
</div>
|
|
12
4
|
</div>
|
|
13
5
|
{% endmacro %}
|
|
14
6
|
|
src/_includes/file-tree.njk
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
{% macro renderNodes(nodes, repoId
|
|
1
|
+
{% macro renderNodes(nodes, repoId) %}
|
|
2
2
|
<ul>
|
|
3
3
|
{% for node in nodes %}
|
|
4
4
|
{% if node.isDirectory %}
|
|
5
5
|
<li>
|
|
6
|
-
<details
|
|
6
|
+
<details>
|
|
7
7
|
<summary>{{ node.name | folderIcon | safe }}{{ node.name }}/</summary>
|
|
8
|
-
{{ renderNodes(node.children, repoId
|
|
8
|
+
{{ renderNodes(node.children, repoId) }}
|
|
9
9
|
</details>
|
|
10
10
|
</li>
|
|
11
11
|
{% else %}
|
|
@@ -18,10 +18,27 @@
|
|
|
18
18
|
hx-target="#file-pane"
|
|
19
19
|
hx-swap="outerHTML"
|
|
20
20
|
hx-push-url="true"
|
|
21
|
-
class="{% if node.path == activePath %}active-file{% endif %}"
|
|
22
21
|
>{{ node.name | fileIcon(node.ext) | safe }}{{ node.name }}<span class="file-size">{{ node.size | formatBytes }}</span></a>
|
|
23
22
|
</li>
|
|
24
23
|
{% endif %}
|
|
25
24
|
{% endfor %}
|
|
26
25
|
</ul>
|
|
27
26
|
{% endmacro %}
|
|
27
|
+
|
|
28
|
+
{# Loads the repo's shared tree page (repos/tree.njk) instead of repeating the
|
|
29
|
+
tree in every file page. client.js marks data-active-href once it arrives.
|
|
30
|
+
ignoreTitle stops htmx copying the tree page's <title> onto this page. #}
|
|
31
|
+
{% macro treeSidebar(repoId, activePath) %}
|
|
32
|
+
{% set treeUrl = "/repos/" + repoId + "/tree/" %}
|
|
33
|
+
<aside
|
|
34
|
+
class="file-explorer-tree"
|
|
35
|
+
hx-get="{{ treeUrl }}"
|
|
36
|
+
hx-trigger="load"
|
|
37
|
+
hx-select="#file-tree > ul"
|
|
38
|
+
hx-target="this"
|
|
39
|
+
hx-swap="innerHTML ignoreTitle:true"
|
|
40
|
+
{% if activePath %}data-active-href="/repos/{{ repoId }}/files/{{ activePath }}/"{% endif %}
|
|
41
|
+
>
|
|
42
|
+
<noscript><a href="{{ treeUrl }}">Browse all files</a></noscript>
|
|
43
|
+
</aside>
|
|
44
|
+
{% endmacro %}
|
src/repos/file-blame.njk
DELETED
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
pagination:
|
|
3
|
-
data: repoFiles
|
|
4
|
-
size: 1
|
|
5
|
-
alias: item
|
|
6
|
-
permalink: "/repos/{{ item.repoId }}/files/{{ item.file.path }}/blame/"
|
|
7
|
-
layout: layout.njk
|
|
8
|
-
eleventyComputed:
|
|
9
|
-
title: "{{ item.file.name }} blame | {{ item.repo.data.title }}"
|
|
10
|
-
description: "Line-by-line blame for {{ item.file.path }} in {{ item.repo.data.title }}"
|
|
11
|
-
robots: "noindex, follow"
|
|
12
|
-
---
|
|
13
|
-
{% from "repo-layout.njk" import repoLayout %}
|
|
14
|
-
{% from "file-layout.njk" import fileLayout %}
|
|
15
|
-
<div id="repo-page">
|
|
16
|
-
{{ repoLayout(item.repo.id, item.repo.data.title, item.repo.data.description, item.repo.data.tags, "Files", item.repo.version) }}
|
|
17
|
-
<div id="repo-content">
|
|
18
|
-
<div class="file-explorer">
|
|
19
|
-
<aside class="file-explorer-tree">
|
|
20
|
-
{% from "file-tree.njk" import renderNodes %}
|
|
21
|
-
{{ renderNodes(item.repo.tree, item.repoId, item.file.path) }}
|
|
22
|
-
</aside>
|
|
23
|
-
<div id="file-pane" class="file-explorer-main">
|
|
24
|
-
{{ fileLayout(item.repoId, item.file.path, item.file.name, item.file.ext, "Blame") }}
|
|
25
|
-
<div id="file-content">
|
|
26
|
-
{% if item.blame.length %}
|
|
27
|
-
<table class="blame">
|
|
28
|
-
<tbody>
|
|
29
|
-
{% for line in item.blame %}
|
|
30
|
-
<tr>
|
|
31
|
-
<td class="blame-meta">
|
|
32
|
-
<a href="/repos/{{ item.repoId }}/commits/{{ line.hash }}/" title="{{ line.author }} — {{ line.date }}">{{ line.shortHash }}</a>
|
|
33
|
-
</td>
|
|
34
|
-
<td class="blame-line-no">{{ line.line }}</td>
|
|
35
|
-
<td class="blame-content"><pre>{{ line.content }}</pre></td>
|
|
36
|
-
</tr>
|
|
37
|
-
{% endfor %}
|
|
38
|
-
</tbody>
|
|
39
|
-
</table>
|
|
40
|
-
{% else %}
|
|
41
|
-
<p>No blame information available for this file.</p>
|
|
42
|
-
{% endif %}
|
|
43
|
-
</div>
|
|
44
|
-
</div>
|
|
45
|
-
</div>
|
|
46
|
-
</div>
|
|
47
|
-
</div>
|
src/repos/file-history.njk
DELETED
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
pagination:
|
|
3
|
-
data: repoFiles
|
|
4
|
-
size: 1
|
|
5
|
-
alias: item
|
|
6
|
-
permalink: "/repos/{{ item.repoId }}/files/{{ item.file.path }}/history/"
|
|
7
|
-
layout: layout.njk
|
|
8
|
-
eleventyComputed:
|
|
9
|
-
title: "{{ item.file.name }} history | {{ item.repo.data.title }}"
|
|
10
|
-
description: "Commit history for {{ item.file.path }} in {{ item.repo.data.title }}"
|
|
11
|
-
robots: "noindex, follow"
|
|
12
|
-
---
|
|
13
|
-
{% from "repo-layout.njk" import repoLayout %}
|
|
14
|
-
{% from "file-layout.njk" import fileLayout %}
|
|
15
|
-
<div id="repo-page">
|
|
16
|
-
{{ repoLayout(item.repo.id, item.repo.data.title, item.repo.data.description, item.repo.data.tags, "Files", item.repo.version) }}
|
|
17
|
-
<div id="repo-content">
|
|
18
|
-
<div class="file-explorer">
|
|
19
|
-
<aside class="file-explorer-tree">
|
|
20
|
-
{% from "file-tree.njk" import renderNodes %}
|
|
21
|
-
{{ renderNodes(item.repo.tree, item.repoId, item.file.path) }}
|
|
22
|
-
</aside>
|
|
23
|
-
<div id="file-pane" class="file-explorer-main">
|
|
24
|
-
{{ fileLayout(item.repoId, item.file.path, item.file.name, item.file.ext, "History") }}
|
|
25
|
-
<div id="file-content">
|
|
26
|
-
{% if item.history.length %}
|
|
27
|
-
{% for commit in item.history %}
|
|
28
|
-
<div class="event">
|
|
29
|
-
<div>
|
|
30
|
-
<a href="/repos/{{ item.repoId }}/commits/{{ commit.hash }}/">{{ commit.shortHash }}</a>
|
|
31
|
-
— <strong>{{ commit.author }}</strong>
|
|
32
|
-
<small class="pull-right"><span>{{ commit.date }}</span></small>
|
|
33
|
-
</div>
|
|
34
|
-
<pre class="commit">{{ commit.message }}</pre>
|
|
35
|
-
</div>
|
|
36
|
-
{% endfor %}
|
|
37
|
-
{% else %}
|
|
38
|
-
<p>No history found for this file.</p>
|
|
39
|
-
{% endif %}
|
|
40
|
-
</div>
|
|
41
|
-
</div>
|
|
42
|
-
</div>
|
|
43
|
-
</div>
|
|
44
|
-
</div>
|
src/repos/file.njk
CHANGED
|
@@ -16,12 +16,10 @@ eleventyComputed:
|
|
|
16
16
|
{{ repoLayout(item.repo.id, item.repo.data.title, item.repo.data.description, item.repo.data.tags, "Files", item.repo.version) }}
|
|
17
17
|
<div id="repo-content">
|
|
18
18
|
<div class="file-explorer">
|
|
19
|
-
<aside class="file-explorer-tree">
|
|
20
|
-
|
|
19
|
+
{% from "file-tree.njk" import treeSidebar %}
|
|
21
|
-
|
|
20
|
+
{{ treeSidebar(item.repoId, item.file.path) }}
|
|
22
|
-
</aside>
|
|
23
21
|
<div id="file-pane" class="file-explorer-main">
|
|
24
|
-
{{ fileLayout(item.
|
|
22
|
+
{{ fileLayout(item.file.name, item.file.ext) }}
|
|
25
23
|
{{ fileContent(item.render, item.file.size, item.file.path) }}
|
|
26
24
|
</div>
|
|
27
25
|
</div>
|
src/repos/files-index.njk
CHANGED
|
@@ -17,13 +17,11 @@ eleventyComputed:
|
|
|
17
17
|
{{ repoLayout(repo.id, repo.data.title, repo.data.description, repo.data.tags, "Files", repo.version) }}
|
|
18
18
|
<div id="repo-content">
|
|
19
19
|
<div class="file-explorer">
|
|
20
|
-
<aside class="file-explorer-tree">
|
|
21
|
-
|
|
20
|
+
{% from "file-tree.njk" import treeSidebar %}
|
|
22
|
-
|
|
21
|
+
{{ treeSidebar(repo.id, activePath) }}
|
|
23
|
-
</aside>
|
|
24
22
|
<div id="file-pane" class="file-explorer-main">
|
|
25
23
|
{% if defaultEntry %}
|
|
26
|
-
{{ fileLayout(
|
|
24
|
+
{{ fileLayout(defaultEntry.file.name, defaultEntry.file.ext) }}
|
|
27
25
|
{{ fileContent(defaultEntry.render, defaultEntry.file.size, defaultEntry.file.path) }}
|
|
28
26
|
{% else %}
|
|
29
27
|
<p class="file-explorer-placeholder">Select a file to view its contents.</p>
|
src/repos/tree.njk
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
---
|
|
2
|
+
pagination:
|
|
3
|
+
data: localRepos
|
|
4
|
+
size: 1
|
|
5
|
+
alias: repo
|
|
6
|
+
permalink: "/repos/{{ repo.id }}/tree/"
|
|
7
|
+
layout: layout.njk
|
|
8
|
+
eleventyComputed:
|
|
9
|
+
title: "Files | {{ repo.data.title }}"
|
|
10
|
+
description: "All source files in {{ repo.data.title }}"
|
|
11
|
+
robots: "noindex, follow"
|
|
12
|
+
---
|
|
13
|
+
{% from "file-tree.njk" import renderNodes %}
|
|
14
|
+
{# The one copy of this repo's file tree: file pages fetch it with htmx (see
|
|
15
|
+
treeSidebar in file-tree.njk). hx-disable makes the links plain navigation
|
|
16
|
+
when this page is opened directly, where there's no #file-pane to target. #}
|
|
17
|
+
<div class="file-explorer">
|
|
18
|
+
<aside id="file-tree" class="file-explorer-tree" hx-disable>
|
|
19
|
+
{{ renderNodes(repo.tree, repo.id) }}
|
|
20
|
+
</aside>
|
|
21
|
+
</div>
|
tests/pages.test.js
CHANGED
|
@@ -117,7 +117,8 @@ describe("static pages", () => {
|
|
|
117
117
|
// content repeated thousands of times across repos) and must not be
|
|
118
118
|
// submitted for indexing.
|
|
119
119
|
assert.doesNotMatch(body, /\/repos\/website\/commits\/[0-9a-f]{20,}\//);
|
|
120
|
-
assert.doesNotMatch(body, /\/repos\/website\/files\/
|
|
120
|
+
assert.doesNotMatch(body, /\/repos\/website\/files\/[^<]+\//);
|
|
121
|
+
assert.doesNotMatch(body, /\/repos\/website\/tree\//);
|
|
121
122
|
});
|
|
122
123
|
|
|
123
124
|
test("unknown route returns the 404 page", async () => {
|
|
@@ -148,7 +149,7 @@ describe("repo pages", () => {
|
|
|
148
149
|
assert.equal(jsonLd.description, description);
|
|
149
150
|
});
|
|
150
151
|
|
|
151
|
-
test("commit and
|
|
152
|
+
test("commit, file and tree pages are noindex to avoid thin/duplicate content", async () => {
|
|
152
153
|
await goto(`/repos/${repoId}/commits/`);
|
|
153
154
|
// Scoped to `.event` to grab an actual commit row, not the repo-layout
|
|
154
155
|
// nav's own "Commits" tab link (which also matches the href prefix and
|
|
@@ -157,10 +158,10 @@ describe("repo pages", () => {
|
|
|
157
158
|
await goto(firstCommitHref);
|
|
158
159
|
assert.equal(await page.locator('meta[name="robots"]').getAttribute("content"), "noindex, follow");
|
|
159
160
|
|
|
160
|
-
await goto(`/repos/${repoId}/files/eleventy.config.js/
|
|
161
|
+
await goto(`/repos/${repoId}/files/eleventy.config.js/`);
|
|
161
162
|
assert.equal(await page.locator('meta[name="robots"]').getAttribute("content"), "noindex, follow");
|
|
162
163
|
|
|
163
|
-
await goto(`/repos/${repoId}/
|
|
164
|
+
await goto(`/repos/${repoId}/tree/`);
|
|
164
165
|
assert.equal(await page.locator('meta[name="robots"]').getAttribute("content"), "noindex, follow");
|
|
165
166
|
});
|
|
166
167
|
|
|
@@ -182,19 +183,52 @@ describe("repo pages", () => {
|
|
|
182
183
|
test("repo files index renders the tree and a default file", async () => {
|
|
183
184
|
const response = await goto(`/repos/${repoId}/files/`);
|
|
184
185
|
assert.equal(response.status(), 200);
|
|
185
|
-
|
|
186
|
+
await page.locator(".file-explorer-tree a.active-file").waitFor();
|
|
186
187
|
});
|
|
187
188
|
|
|
188
|
-
test("a specific file, its history and blame views
|
|
189
|
+
test("a specific file renders, and its history and blame views are gone", async () => {
|
|
189
190
|
const filePath = "eleventy.config.js";
|
|
190
191
|
const fileRes = await goto(`/repos/${repoId}/files/${filePath}/`);
|
|
191
192
|
assert.equal(fileRes.status(), 200);
|
|
192
193
|
|
|
193
|
-
|
|
194
|
+
assert.equal((await goto(`/repos/${repoId}/files/${filePath}/history/`)).status(), 404);
|
|
195
|
+
assert.equal((await goto(`/repos/${repoId}/files/${filePath}/blame/`)).status(), 404);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("file pages load the shared tree instead of embedding it", async () => {
|
|
199
|
+
const filePath = "src/_data/site.js";
|
|
200
|
+
const activeHref = `/repos/${repoId}/files/${filePath}/`;
|
|
201
|
+
const response = await goto(activeHref);
|
|
202
|
+
const html = await response.text();
|
|
203
|
+
assert.doesNotMatch(html, /href="\/repos\/website\/files\/package\.json\/"/);
|
|
204
|
+
const title = await page.title();
|
|
205
|
+
|
|
206
|
+
const active = page.locator(".file-explorer-tree a.active-file");
|
|
207
|
+
await active.waitFor();
|
|
208
|
+
assert.equal(await active.getAttribute("href"), activeHref);
|
|
209
|
+
// Only the folders above the active file open, and the fetched tree page's
|
|
210
|
+
// <title> doesn't replace this page's.
|
|
211
|
+
assert.equal(await page.locator(".file-explorer-tree details[open]").count(), 2);
|
|
194
|
-
assert.equal(
|
|
212
|
+
assert.equal(await page.title(), title);
|
|
213
|
+
});
|
|
195
214
|
|
|
215
|
+
test("clicking a file in the loaded tree swaps the pane and moves the highlight", async () => {
|
|
196
|
-
|
|
216
|
+
await goto(`/repos/${repoId}/files/eleventy.config.js/`);
|
|
217
|
+
await page.locator(".file-explorer-tree a.active-file").waitFor();
|
|
218
|
+
const href = `/repos/${repoId}/files/package.json/`;
|
|
219
|
+
await page.click(`.file-explorer-tree a[href='${href}']`);
|
|
220
|
+
await page.waitForURL(`${server.url}${href}`);
|
|
221
|
+
await page.locator("#file-pane .title", { hasText: "package.json" }).waitFor();
|
|
222
|
+
assert.equal(await page.locator(".file-explorer-tree a.active-file").getAttribute("href"), href);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("the tree page works on its own, with plain links", async () => {
|
|
226
|
+
const response = await goto(`/repos/${repoId}/tree/`);
|
|
197
|
-
assert.equal(
|
|
227
|
+
assert.equal(response.status(), 200);
|
|
228
|
+
const href = `/repos/${repoId}/files/package.json/`;
|
|
229
|
+
await page.click(`#file-tree a[href='${href}']`);
|
|
230
|
+
await page.waitForURL(`${server.url}${href}`);
|
|
231
|
+
await page.locator("#file-pane .title", { hasText: "package.json" }).waitFor();
|
|
198
232
|
});
|
|
199
233
|
|
|
200
234
|
test("repo nav tabs swap content via htmx without a full reload", async () => {
|
|
@@ -206,9 +240,9 @@ describe("repo pages", () => {
|
|
|
206
240
|
});
|
|
207
241
|
|
|
208
242
|
test("no unexpected console errors were logged across visited pages", () => {
|
|
209
|
-
// The deliberate 404
|
|
243
|
+
// The deliberate 404 tests above trigger a browser-level "failed to load
|
|
210
|
-
// resource" console entry for
|
|
244
|
+
// resource" console entry for their own navigation requests — that's the
|
|
211
|
-
// behavior under test there, not a bug, so
|
|
245
|
+
// behavior under test there, not a bug, so they're excluded here.
|
|
212
|
-
const unexpected = consoleErrors.filter((msg) => !
|
|
246
|
+
const unexpected = consoleErrors.filter((msg) => !/this-page-does-not-exist|\/(history|blame)\/\]/.test(msg));
|
|
213
247
|
assert.deepEqual(unexpected, []);
|
|
214
248
|
});
|