website

#astro#js#html#css

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

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


20e9f21 — pyrossh 2026-11-06T15:29:20+05:30
Fix markdown links and render docs on file pages
assets/css/markdown.css CHANGED
@@ -39,6 +39,13 @@
39
39
  padding-bottom: 1rem;
40
40
  }
41
41
 
42
+ /* shared.css resets links to inherit their color with no underline, which
43
+ leaves links in prose indistinguishable from the text around them. */
44
+ a[href] {
45
+ color: var(--color-link);
46
+ text-decoration: underline;
47
+ }
48
+
42
49
  /* shared.css resets ul/ol to list-style: none globally (for nav-style
43
50
  lists elsewhere in the site), so markdown content needs its bullets/
44
51
  numbering and indentation restored here, plus breathing room between
assets/css/repos.css CHANGED
@@ -375,6 +375,14 @@
375
375
  border-radius: 0;
376
376
  }
377
377
  }
378
+
379
+ /* Rendered markdown brings its own padding (see #repo-content
380
+ .markdown-body), so the pane only supplies the matching background
381
+ for documents shorter than the pane. */
382
+ &:has(> .markdown-body) {
383
+ padding: 0;
384
+ background: var(--color-box-bg);
385
+ }
378
386
  }
379
387
  }
380
388
 
src/_data/localRepos.js CHANGED
@@ -1,13 +1,17 @@
1
- import MarkdownIt from "markdown-it";
2
1
  import { discoverLocalRepos, resolveReadmeImages } from "../_helpers/gitRepos.js";
3
- import { highlightFenceCode } from "../_helpers/highlightCode.js";
2
+ import { renderRepoMarkdown } from "../_helpers/repoMarkdown.js";
4
3
 
4
+ const renderReadme = (repo) =>
5
+ resolveReadmeImages(
5
- const md = new MarkdownIt({ html: true, linkify: true, highlight: highlightFenceCode });
6
+ renderRepoMarkdown(repo.readme, { repoId: repo.id, files: repo.files, filePath: "README.md" }),
7
+ repo.id,
8
+ repo.dir,
9
+ );
6
10
 
7
11
  export default async () => {
8
12
  const repos = discoverLocalRepos();
9
13
  return repos.map((repo) => ({
10
14
  ...repo,
11
- readmeHtml: repo.readme ? resolveReadmeImages(md.render(repo.readme), repo.id, repo.dir) : undefined,
15
+ readmeHtml: repo.readme ? renderReadme(repo) : undefined,
12
16
  }));
13
17
  };
src/_data/repoFiles.js CHANGED
@@ -7,20 +7,28 @@ import {
7
7
  LARGE_FILE_SIZE,
8
8
  discoverLocalRepos,
9
9
  getHeadSha,
10
+ inlineMarkdownImages,
10
11
  isDirty,
11
12
  toDataUri,
12
13
  } from "../_helpers/gitRepos.js";
13
14
  import { highlightCode } from "../_helpers/highlightCode.js";
14
15
  import { mapPool } from "../_helpers/pool.js";
16
+ import { renderRepoMarkdown } from "../_helpers/repoMarkdown.js";
15
17
 
16
18
  const CONCURRENCY = 8;
19
+ const MARKDOWN_EXTENSIONS = new Set(["md", "markdown"]);
17
20
 
18
- const renderFile = (repoDir, file) => {
21
+ const renderFile = (repo, file) => {
19
- const full = path.join(repoDir, file.path);
22
+ const full = path.join(repo.dir, file.path);
20
23
  if (BINARY_EXTENSIONS.has(file.ext)) return { kind: "binary" };
21
24
  if (file.size > LARGE_FILE_SIZE) return { kind: "large" };
22
25
  if (IMAGE_EXTENSIONS.has(file.ext)) return { kind: "image", src: toDataUri(fs.readFileSync(full), file.ext) };
26
+ const source = fs.readFileSync(full, "utf8");
27
+ if (MARKDOWN_EXTENSIONS.has(file.ext)) {
28
+ const html = renderRepoMarkdown(source, { repoId: repo.id, files: repo.files, filePath: file.path });
29
+ return { kind: "markdown", html: inlineMarkdownImages(html, repo.dir, file.path) };
30
+ }
23
- const { html, lang } = highlightCode(fs.readFileSync(full, "utf8"), file.ext);
31
+ const { html, lang } = highlightCode(source, file.ext);
24
32
  return { kind: "text", html, lang };
25
33
  };
26
34
 
@@ -30,13 +38,16 @@ export default async () => {
30
38
  for (const repo of repos) {
31
39
  const sha = getHeadSha(repo.dir);
32
40
  const cacheable = sha && !isDirty(repo.dir);
41
+ // The version is part of the key because the cache is otherwise keyed by
42
+ // HEAD: bump it when renderFile's output changes, or unchanged repos keep
43
+ // serving the old render.
33
- const cacheKey = `files-${repo.id}`;
44
+ const cacheKey = `files-v2-${repo.id}`;
34
45
 
35
46
  let entries = cacheable ? readCache(cacheKey, sha) : null;
36
47
  if (!entries) {
37
48
  entries = await mapPool(repo.files, CONCURRENCY, async (file) => ({
38
49
  file,
39
- render: renderFile(repo.dir, file),
50
+ render: renderFile(repo, file),
40
51
  }));
41
52
  if (cacheable) writeCache(cacheKey, sha, entries);
42
53
  }
src/_helpers/gitRepos.js CHANGED
@@ -220,6 +220,16 @@ export const toDataUri = (buf, ext) => {
220
220
  return `data:${mime};base64,${buf.toString("base64")}`;
221
221
  };
222
222
 
223
+ // Percent-decodes a path from a markdown link or image. Malformed escapes are
224
+ // kept as written, since hand-written markdown can contain a bare `%`.
225
+ export const decodePath = (encoded) => {
226
+ try {
227
+ return decodeURIComponent(encoded);
228
+ } catch {
229
+ return encoded;
230
+ }
231
+ };
232
+
223
233
  const isAbsoluteSrc = (src) => /^([a-z][a-z0-9+.-]*:)?\/\//i.test(src) || src.startsWith("data:");
224
234
 
225
235
  const IMG_SRC_PATTERNS = [
@@ -260,6 +270,20 @@ export const resolveReadmeImages = (html, repoId, repoDir) =>
260
270
  return `${pre}/repos/${repoId}/raw/${path.posix.normalize(src)}${post}`;
261
271
  });
262
272
 
273
+ // Markdown shown on a file page. Only README images get a raw/ copy, so these
274
+ // are inlined the same way image files' own pages are, with paths relative to
275
+ // the markdown file rather than the repo root.
276
+ export const inlineMarkdownImages = (html, repoDir, filePath) =>
277
+ html.replace(/(<img\b[^>]*?(?<![\w-])src\s*=\s*)(["'])(.*?)\2/gi, (match, pre, quote, src) => {
278
+ if (!src || isAbsoluteSrc(src)) return match;
279
+ const relPath = path.posix.join(path.posix.dirname(filePath), decodePath(src.split(/[?#]/)[0]));
280
+ if (!isLocalRepoImage(repoDir, relPath)) return match;
281
+ const full = path.join(repoDir, relPath);
282
+ if (fs.statSync(full).size > LARGE_FILE_SIZE) return match;
283
+ const ext = path.extname(relPath).slice(1).toLowerCase();
284
+ return `${pre}${quote}${toDataUri(fs.readFileSync(full), ext)}${quote}`;
285
+ });
286
+
263
287
  const readCargoVersion = (repoDir) => {
264
288
  const p = path.join(repoDir, "Cargo.toml");
265
289
  if (!fs.existsSync(p)) return undefined;
src/_helpers/repoMarkdown.js ADDED
@@ -0,0 +1,87 @@
1
+ import path from "node:path";
2
+ import MarkdownIt from "markdown-it";
3
+ import { decodePath } from "./gitRepos.js";
4
+ import { highlightFenceCode } from "./highlightCode.js";
5
+
6
+ const md = new MarkdownIt({ html: true, linkify: true, highlight: highlightFenceCode });
7
+
8
+ // Same ids GitHub generates, since that's what `#section` links in these repos
9
+ // were written against. A repeated heading gets -1, -2, ... appended.
10
+ const headingSlug = (text) =>
11
+ text
12
+ .trim()
13
+ .toLowerCase()
14
+ .replace(/[^\p{L}\p{M}\p{N}\s_-]/gu, "")
15
+ .replace(/\s/g, "-");
16
+
17
+ md.renderer.rules.heading_open = (tokens, idx, options, env, self) => {
18
+ const text = tokens[idx + 1].children
19
+ .filter((t) => t.type === "text" || t.type === "code_inline")
20
+ .map((t) => t.content)
21
+ .join("");
22
+ const slug = headingSlug(text);
23
+ // An image-only heading has no text to link to, so it gets no id.
24
+ if (slug) {
25
+ let id = slug;
26
+ for (let n = 1; env.headingIds.has(id); n++) id = `${slug}-${n}`;
27
+ env.headingIds.add(id);
28
+ tokens[idx].attrSet("id", id);
29
+ }
30
+ return self.renderToken(tokens, idx, options);
31
+ };
32
+
33
+ // The first line must look like a YAML key, so a document that merely opens
34
+ // with a `---` rule keeps its content.
35
+ const FRONT_MATTER = /^---\r?\n[\w-]+:[\s\S]*?\r?\n---\r?\n/;
36
+
37
+ const encodePath = (filePath) => filePath.split("/").map(encodeURIComponent).join("/");
38
+
39
+ // Folders have no page of their own, so a link to one opens its README or else
40
+ // its first file, which also expands that folder in the file tree.
41
+ const findLinkTarget = (files, target) => {
42
+ if (files.some((f) => f.path === target)) return target;
43
+ const inFolder = files.filter((f) => f.path.startsWith(`${target}/`));
44
+ const readme = inFolder.find((f) => f.path.toLowerCase() === `${target}/readme.md`.toLowerCase());
45
+ return (readme ?? inFolder[0])?.path;
46
+ };
47
+
48
+ // Repo markdown links relative to its own file, as GitHub resolves them. Here
49
+ // that file's content lives at /repos/<id>/files/<path>/, so those links need
50
+ // rewriting, and a leading `/` means the repo root. Targets not in the repo are
51
+ // left as written. `href` is still HTML-escaped, so `&` is `&amp;` here.
52
+ const resolveLink = (href, repoId, files, filePath) => {
53
+ if (/^(#|\/\/|[a-z][a-z0-9+.-]*:)/i.test(href)) return href;
54
+
55
+ const [, linkPath, suffix] = href.match(/^([^?#]*)(.*)$/s);
56
+ if (!linkPath) return href;
57
+
58
+ const decoded = decodePath(linkPath.replaceAll("&amp;", "&"));
59
+ const base = decoded.startsWith("/") ? "." : path.posix.dirname(filePath);
60
+ const target = path.posix.join(base, decoded).replace(/\/$/, "");
61
+ if (target === ".") return `/repos/${repoId}/files/${suffix}`;
62
+
63
+ const file = findLinkTarget(files, target);
64
+ if (file) return `/repos/${repoId}/files/${encodePath(file)}/${suffix}`;
65
+
66
+ // Scheme-less external links, e.g. [Teensy](www.pjrc.com/store/teensy3.html)
67
+ return /^www\./i.test(href) ? `https://${href}` : href;
68
+ };
69
+
70
+ /**
71
+ * Render a markdown file from a repo, pointing its relative links at this
72
+ * site's file pages. Runs over the final HTML rather than a markdown-it link
73
+ * rule so raw <a> tags in the markdown are covered too; code blocks can't match
74
+ * because their `<` is already escaped.
75
+ *
76
+ * @param {string} source markdown text
77
+ * @param {{ repoId: string, files: { path: string }[], filePath: string }} context
78
+ * filePath is the markdown file's path within the repo
79
+ * @returns {string} HTML
80
+ */
81
+ export const renderRepoMarkdown = (source, { repoId, files, filePath }) =>
82
+ md
83
+ .render(source.replace(FRONT_MATTER, ""), { headingIds: new Set() })
84
+ .replace(
85
+ /(<a\b[^>]*?(?<![\w-])href\s*=\s*)(["'])(.*?)\2/gi,
86
+ (_, pre, quote, href) => `${pre}${quote}${resolveLink(href, repoId, files, filePath)}${quote}`,
87
+ );
src/_includes/file-layout.njk CHANGED
@@ -12,6 +12,8 @@
12
12
  <p>File too large to display ({{ size }} bytes).</p>
13
13
  {% elif render.kind == "image" %}
14
14
  <img src="{{ render.src }}" alt="{{ filepath }}" />
15
+ {% elif render.kind == "markdown" %}
16
+ <article class="markdown-body">{{ render.html | safe }}</article>
15
17
  {% else %}
16
18
  <pre class="code-view" data-lang="{{ render.lang }}"><code class="language-{{ render.lang }}">{{ render.html | safe }}</code></pre>
17
19
  {% endif %}
tests/pages.test.js CHANGED
@@ -237,6 +237,20 @@ describe("repo pages", () => {
237
237
  }
238
238
  });
239
239
 
240
+ test("markdown files render as documents with visible links", async () => {
241
+ await goto(`/repos/${repoId}/files/README.md/`);
242
+ await page.locator("#file-content > .markdown-body").waitFor();
243
+ assert.equal(await page.locator("#file-content pre.code-view").count(), 0);
244
+
245
+ const link = page.locator("#file-content .markdown-body a[href]").first();
246
+ const style = await link.evaluate((el) => {
247
+ const { color, textDecorationLine } = getComputedStyle(el);
248
+ return { color, textDecorationLine, parentColor: getComputedStyle(el.parentElement).color };
249
+ });
250
+ assert.equal(style.textDecorationLine, "underline");
251
+ assert.notEqual(style.color, style.parentColor);
252
+ });
253
+
240
254
  test("repo files index renders the tree and a default file", async () => {
241
255
  const response = await goto(`/repos/${repoId}/files/`);
242
256
  assert.equal(response.status(), 200);
tests/repoMarkdown.test.js ADDED
@@ -0,0 +1,142 @@
1
+ import { after, describe, test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { LARGE_FILE_SIZE, inlineMarkdownImages } from "../src/_helpers/gitRepos.js";
7
+ import { renderRepoMarkdown } from "../src/_helpers/repoMarkdown.js";
8
+
9
+ const files = ["README.md", "docs/language.md", "docs/std.md", "examples/arrays.plum", "examples/basics.plum", "guide/intro.md", "guide/README.md", "my docs/a b.md", "c#/q&a.md", "www.site/index.md"].map((p) => ({ path: p }));
10
+
11
+ const render = (source, filePath = "README.md") => renderRepoMarkdown(source, { repoId: "plum", files, filePath });
12
+ const href = (source, filePath) => render(source, filePath).match(/href="([^"]*)"/)[1];
13
+
14
+ describe("renderRepoMarkdown links", () => {
15
+ test("relative file links point at the repo's file pages", () => {
16
+ assert.equal(href("[x](docs/language.md)"), "/repos/plum/files/docs/language.md/");
17
+ assert.equal(href("[x](./docs/language.md)"), "/repos/plum/files/docs/language.md/");
18
+ });
19
+
20
+ test("links resolve against the markdown file's own folder", () => {
21
+ assert.equal(href("[x](std.md)", "docs/language.md"), "/repos/plum/files/docs/std.md/");
22
+ assert.equal(href("[x](../README.md)", "docs/language.md"), "/repos/plum/files/README.md/");
23
+ });
24
+
25
+ test("a section in another file keeps its fragment", () => {
26
+ assert.equal(href("[x](docs/std.md#strings)"), "/repos/plum/files/docs/std.md/#strings");
27
+ });
28
+
29
+ test("folder links open the folder's README, else its first file", () => {
30
+ assert.equal(href("[x](guide/)"), "/repos/plum/files/guide/README.md/");
31
+ assert.equal(href("[x](../examples)", "docs/language.md"), "/repos/plum/files/examples/arrays.plum/");
32
+ });
33
+
34
+ test("a link to the repo root opens the Code tab", () => {
35
+ assert.equal(href("[x](..)", "docs/language.md"), "/repos/plum/files/");
36
+ });
37
+
38
+ test("paths with spaces stay encoded", () => {
39
+ assert.equal(href("[x](my%20docs/a%20b.md)"), "/repos/plum/files/my%20docs/a%20b.md/");
40
+ });
41
+
42
+ test("filenames with URL-special characters are encoded per segment", () => {
43
+ assert.equal(href("[x](c%23/q&a.md)"), "/repos/plum/files/c%23/q%26a.md/");
44
+ });
45
+
46
+ test("a leading slash means the repo root", () => {
47
+ assert.equal(href("[x](/docs/std.md)", "guide/intro.md"), "/repos/plum/files/docs/std.md/");
48
+ });
49
+
50
+ test("raw HTML links are resolved too, with either quote style", () => {
51
+ assert.equal(href('<a href="docs/std.md">x</a>'), "/repos/plum/files/docs/std.md/");
52
+ assert.match(render("<a href='docs/std.md'>x</a>"), /href='\/repos\/plum\/files\/docs\/std\.md\/'/);
53
+ });
54
+
55
+ test("data-href isn't treated as the link", () => {
56
+ assert.match(render('<a data-href="docs/std.md">x</a>'), /data-href="docs\/std\.md"/);
57
+ });
58
+
59
+ test("other links are left alone", () => {
60
+ for (const link of ["https://example.com/a.md", "mailto:[email protected]", "#section", "/posts/", "?x=1", "docs/missing.md", "../../outside.md"]) {
61
+ assert.equal(href(`[x](${link})`), link);
62
+ }
63
+ });
64
+
65
+ test("scheme-less www links become external, unless the repo has that path", () => {
66
+ assert.equal(href("[x](www.pjrc.com/store/teensy3.html)"), "https://www.pjrc.com/store/teensy3.html");
67
+ assert.equal(href("[x](www.site/index.md)"), "/repos/plum/files/www.site/index.md/");
68
+ });
69
+
70
+ test("links shown in code blocks are not rewritten", () => {
71
+ assert.match(render('```html\n<a href="docs/std.md">x</a>\n```'), /docs\/std\.md/);
72
+ assert.doesNotMatch(render('```html\n<a href="docs/std.md">x</a>\n```'), /\/repos\/plum/);
73
+ });
74
+ });
75
+
76
+ describe("renderRepoMarkdown headings", () => {
77
+ test("headings get ids that match section links", () => {
78
+ assert.match(render("## Pattern matching"), /<h2 id="pattern-matching">/);
79
+ assert.match(render("## The `plum` CLI: tools!"), /<h2 id="the-plum-cli-tools">/);
80
+ });
81
+
82
+ test("repeated headings get distinct ids, in each render", () => {
83
+ const html = render("## Usage\n\n## Usage");
84
+ assert.match(html, /id="usage"/);
85
+ assert.match(html, /id="usage-1"/);
86
+ assert.match(render("## Usage"), /id="usage"/);
87
+ assert.doesNotMatch(render("## Usage"), /usage-1/);
88
+ });
89
+
90
+ test("a heading that collides with a generated id still gets a unique one", () => {
91
+ const ids = [...render("## Usage\n\n## Usage\n\n## Usage-1").matchAll(/id="([^"]+)"/g)].map((m) => m[1]);
92
+ assert.equal(new Set(ids).size, 3);
93
+ });
94
+
95
+ test("image-only headings get no id", () => {
96
+ assert.match(render("## ![logo](logo.png)"), /<h2>/);
97
+ });
98
+
99
+ test("combining marks are kept", () => {
100
+ assert.match(render("## हिन्दी"), /id="हिन्दी"/);
101
+ });
102
+ });
103
+
104
+ describe("renderRepoMarkdown front matter", () => {
105
+ test("isn't rendered as content", () => {
106
+ const html = render("---\ntitle: Hello\n---\n\n# Body");
107
+ assert.doesNotMatch(html, /title: Hello/);
108
+ assert.match(html, /<h1 id="body">/);
109
+ });
110
+
111
+ test("a document opening with a rule keeps its content", () => {
112
+ assert.match(render("---\n\nKeep me\n\n---\n\nAnd me"), /Keep me/);
113
+ });
114
+ });
115
+
116
+ describe("inlineMarkdownImages", () => {
117
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "repo-markdown-"));
118
+ const repoDir = path.join(tempDir, "repo");
119
+ fs.mkdirSync(path.join(repoDir, "docs"), { recursive: true });
120
+ fs.writeFileSync(path.join(repoDir, "docs", "my img.png"), "png");
121
+ fs.writeFileSync(path.join(repoDir, "docs", "big.png"), Buffer.alloc(LARGE_FILE_SIZE + 1));
122
+ fs.writeFileSync(path.join(tempDir, "outside.png"), "png");
123
+ const inline = (html) => inlineMarkdownImages(html, repoDir, "docs/game.md");
124
+
125
+ after(() => fs.rmSync(tempDir, { recursive: true, force: true }));
126
+
127
+ test("inlines images relative to the markdown file, ignoring encoding and suffixes", () => {
128
+ assert.match(inline('<img src="my%20img.png?raw=true">'), /<img src="data:image\/png;base64,/);
129
+ assert.match(inline("<img src='my%20img.png'>"), /<img src='data:image\/png;base64,/);
130
+ });
131
+
132
+ test("leaves oversized, missing and out-of-repo images alone", () => {
133
+ for (const html of ['<img src="big.png">', '<img src="nope.png">', '<img src="../../outside.png">']) {
134
+ assert.equal(inline(html), html);
135
+ }
136
+ });
137
+
138
+ test("data-src isn't treated as the image", () => {
139
+ const html = '<img data-src="my%20img.png">';
140
+ assert.equal(inline(html), html);
141
+ });
142
+ });