website

#astro#js#html#css

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 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/blame
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, per-file history, and per-file blame are all
27
+ tree/content, commit list, and per-commit diffs are all read straight from git
28
- read straight from git (`git ls-tree`, `git log`, `git show`, `git blame`) at build time.
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 the .active-file
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
- // class painted at page-load time never moves on its own. Move it here,
41
+ // on htmx's response. data-active-href moves with it so a tree restored from
27
- // synchronously on click, instead of waiting on htmx's response.
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/blame). Restored from the
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, blame, file title)
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/blame font sizes
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, .file-explorer and table.blame are all real,
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, file-history.njk. pre.commit is nested here too since
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) or
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 with the
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 tab switching on repo pages (Readme/
52
+ // htmx powers no-full-page-reload navigation on repo pages (Readme/Commits/
53
- // Commits/Files, Contents/History/Blame) — hx-select pulls #repo-content
53
+ // Files tabs, the file tree) — hx-select pulls #repo-page or #file-pane out
54
- // or #file-content out of the fetched static page and swaps it in place.
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
- const render = renderFile(repo.dir, file);
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/file-history.njk/file-blame.njk only ever read `id`/`data`/
44
+ // file.njk only ever reads `id`/`data`/`version` off `item.repo` — not
51
- // `tree`/`version` off `item.repo` — not `files`/`commits`/`readme`.
45
+ // `files`/`commits`/`readme`/`tree`. See the same note in repoCommits.js:
52
- // See the same note in repoCommits.js: Eleventy clones each page's data
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, tree: repo.tree, version: repo.version };
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
- * history, blame, syntax highlighting) computed in repoCommits.js/
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, history, blame, highlighting) computed in repoCommits.js/
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(repo, filepath, filename, ext, tab) %}
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, activePath) %}
1
+ {% macro renderNodes(nodes, repoId) %}
2
2
  <ul>
3
3
  {% for node in nodes %}
4
4
  {% if node.isDirectory %}
5
5
  <li>
6
- <details {% if node.path | isAncestorDir(activePath) %}open{% endif %}>
6
+ <details>
7
7
  <summary>{{ node.name | folderIcon | safe }}{{ node.name }}/</summary>
8
- {{ renderNodes(node.children, repoId, activePath) }}
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
- {% from "file-tree.njk" import renderNodes %}
19
+ {% from "file-tree.njk" import treeSidebar %}
21
- {{ renderNodes(item.repo.tree, item.repoId, item.file.path) }}
20
+ {{ treeSidebar(item.repoId, item.file.path) }}
22
- </aside>
23
21
  <div id="file-pane" class="file-explorer-main">
24
- {{ fileLayout(item.repoId, item.file.path, item.file.name, item.file.ext, "Contents") }}
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
- {% from "file-tree.njk" import renderNodes %}
20
+ {% from "file-tree.njk" import treeSidebar %}
22
- {{ renderNodes(repo.tree, repo.id, activePath) }}
21
+ {{ treeSidebar(repo.id, activePath) }}
23
- </aside>
24
22
  <div id="file-pane" class="file-explorer-main">
25
23
  {% if defaultEntry %}
26
- {{ fileLayout(repo.id, defaultEntry.file.path, defaultEntry.file.name, defaultEntry.file.ext, "Contents") }}
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\/.*\/(history|blame)\//);
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 file-history/blame pages are noindex to avoid thin/duplicate content", async () => {
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/history/`);
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}/files/eleventy.config.js/blame/`);
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
- assert.ok((await page.locator(".file-explorer-tree").count()) > 0);
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 render", async () => {
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
- const historyRes = await goto(`/repos/${repoId}/files/${filePath}/history/`);
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(historyRes.status(), 200);
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
- const blameRes = await goto(`/repos/${repoId}/files/${filePath}/blame/`);
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(blameRes.status(), 200);
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 test above triggers a browser-level "failed to load
243
+ // The deliberate 404 tests above trigger a browser-level "failed to load
210
- // resource" console entry for its own navigation request — that's the
244
+ // resource" console entry for their own navigation requests — that's the
211
- // behavior under test there, not a bug, so it's excluded here.
245
+ // behavior under test there, not a bug, so they're excluded here.
212
- const unexpected = consoleErrors.filter((msg) => !msg.includes("this-page-does-not-exist"));
246
+ const unexpected = consoleErrors.filter((msg) => !/this-page-does-not-exist|\/(history|blame)\/\]/.test(msg));
213
247
  assert.deepEqual(unexpected, []);
214
248
  });