website

#astro#js#html#css

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

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


9abd005pyrossh 2026-09-08T17:05:42+05:30
Improve SEO for homepage, repo pages, and posts
eleventy.config.js CHANGED
@@ -4,6 +4,7 @@ import syntaxHighlight from "@11ty/eleventy-plugin-syntaxhighlight";
4
4
  import pluginRss from "@11ty/eleventy-plugin-rss";
5
5
  import { fileIcon, folderIcon, iconSpriteDefs } from "./src/_helpers/fileIcons.js";
6
6
  import { discoverLocalRepos, findReadmeImagePaths } from "./src/_helpers/gitRepos.js";
7
+ import site from "./src/_data/site.js";
7
8
 
8
9
  // theme-switcher (src/components/theme-switcher.js) used to be a Web
9
10
  // Component (via @elenajs/core + an @elenajs/ssr build-time transform
@@ -105,7 +106,7 @@ export default function (eleventyConfig) {
105
106
  // under dist/, so it reads the already-copied output rather than the
106
107
  // assets/ source tree (that's also where diff2html.min.css lands, copied
107
108
  // from node_modules above — there's no separate "source" copy of it).
108
- eleventyConfig.on("eleventy.after", ({ dir }) => {
109
+ eleventyConfig.on("eleventy.after", ({ dir, results }) => {
109
110
  const outputDir = dir.output;
110
111
  const merged = CSS_FILES_TO_MERGE.map((file) => readFileSync(path.join(outputDir, file), "utf8")).join("\n");
111
112
  writeFileSync(path.join(outputDir, "assets/css/bundle.css"), merged);
@@ -115,6 +116,23 @@ export default function (eleventyConfig) {
115
116
  // every repo page's fileIcon()/folderIcon() <use> just points at this
116
117
  // one shared file instead of shipping the ~30-icon sprite body again.
117
118
  writeFileSync(path.join(outputDir, "assets/icons/sprite.svg"), iconSpriteDefs());
119
+
120
+ // Built from the final rendered `results` (not `collections.all` in a
121
+ // template) because collections.all is mutated in-place as paginated
122
+ // templates (repo.njk/commits.njk/etc, 14 repos x several templates)
123
+ // resolve their data cascades one at a time — a sitemap.njk template
124
+ // rendered via the normal template pipeline only ever sees whatever
125
+ // slice of that pagination had completed by the time its own turn came
126
+ // up, silently producing a near-empty sitemap. `results` is only
127
+ // available once every page has actually finished rendering.
128
+ const urls = results
129
+ .filter((page) => page.url && page.content && !page.content.includes('name="robots" content="noindex'))
130
+ .map((page) => ` <url>\n <loc>${site.url}${page.url}</loc>\n </url>`)
131
+ .join("\n");
132
+ writeFileSync(
133
+ path.join(outputDir, "sitemap.xml"),
134
+ `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls}\n</urlset>\n`,
135
+ );
118
136
  });
119
137
 
120
138
  return {
src/_includes/layout.njk CHANGED
@@ -14,13 +14,19 @@
14
14
  <link rel="alternate" type="application/rss+xml" title="{{ title or site.title }}" href="/rss.xml" />
15
15
  <link rel="canonical" href="{{ site.url }}{{ page.url }}" />
16
16
  <link rel="stylesheet" href="/assets/css/bundle.css" />
17
+ {% set pageTitle = (title + " | " + site.title) if (title and title != site.title) else site.title %}
17
- <title>{{ title or site.title }}</title>
18
+ <title>{{ pageTitle }}</title>
19
+ <meta name="robots" content="{{ robots or 'index, follow' }}" />
18
- <meta name="title" content="{{ title or site.title }}" />
20
+ <meta name="title" content="{{ pageTitle }}" />
19
21
  <meta name="description" content="{{ description or site.description }}" />
22
+ <meta property="og:type" content="{{ ogType or 'website' }}" />
23
+ <meta property="og:url" content="{{ site.url }}{{ page.url }}" />
20
- <meta property="og:title" content="{{ title or site.title }}" />
24
+ <meta property="og:title" content="{{ pageTitle }}" />
21
25
  <meta property="og:description" content="{{ description or site.description }}" />
22
- <meta property="og:image" content="/assets/icons/icon.svg" />
26
+ <meta property="og:image" content="{{ site.url }}/assets/icons/icon.svg" />
23
27
  <meta name="twitter:card" content="summary_large_image" />
28
+ <meta name="twitter:title" content="{{ pageTitle }}" />
29
+ <meta name="twitter:description" content="{{ description or site.description }}" />
24
30
  <script defer src="/assets/client.js"></script>
25
31
  <script src="/assets/vendor/htmx.min.js"></script>
26
32
  </head>
src/_includes/post.njk CHANGED
@@ -1,6 +1,24 @@
1
1
  ---
2
2
  layout: layout.njk
3
+ eleventyComputed:
4
+ ogType: article
3
5
  ---
6
+ <script type="application/ld+json">
7
+ {
8
+ "@context": "https://schema.org",
9
+ "@type": "BlogPosting",
10
+ "headline": "{{ title }}",
11
+ "description": "{{ description }}",
12
+ "datePublished": "{{ pubDate | dateToRfc3339 }}",
13
+ "url": "{{ site.url }}{{ page.url }}",
14
+ "keywords": "{{ tags | join(', ') }}",
15
+ "author": {
16
+ "@type": "Person",
17
+ "name": "pyrossh",
18
+ "url": "{{ site.url }}"
19
+ }
20
+ }
21
+ </script>
4
22
  <article>
5
23
  <h1 style="view-transition-name:post-title-{{ page.fileSlug }};text-align:left;font-size:1.8rem;font-weight:bold;line-height:1;margin-top:0.5rem;margin-bottom:0.1rem">{{ title }}</h1>
6
24
  <h2 style="font-size:1.1rem;font-weight:500">{{ description }}</h2>
src/index.njk CHANGED
@@ -4,6 +4,21 @@ title: pyrossh
4
4
  description: "Tech Lead from Bangalore who likes to create frameworks and programming languages."
5
5
  permalink: /
6
6
  ---
7
+ <script type="application/ld+json">
8
+ {
9
+ "@context": "https://schema.org",
10
+ "@type": "Person",
11
+ "name": "pyrossh",
12
+ "url": "{{ site.url }}",
13
+ "description": "{{ description }}",
14
+ "sameAs": [
15
+ "https://www.linkedin.com/in/pyrossh",
16
+ "https://github.com/pyrossh",
17
+ "https://crates.io/users/pyrossh",
18
+ "https://www.npmjs.com/~pyrossh"
19
+ ]
20
+ }
21
+ </script>
7
22
  <div class="pageContainer">
8
23
  <h1 class="title">Hello!</h1>
9
24
  <p class="description">
src/repos/commit.njk CHANGED
@@ -7,6 +7,8 @@ permalink: "/repos/{{ item.repoId }}/commits/{{ item.commit.hash }}/"
7
7
  layout: layout.njk
8
8
  eleventyComputed:
9
9
  title: "{{ item.commit.shortHash }} | {{ item.repo.data.title }}"
10
+ description: "{{ item.commit.message }}"
11
+ robots: "noindex, follow"
10
12
  ---
11
13
  {% from "repo-layout.njk" import repoLayout %}
12
14
  <div id="repo-page">
src/repos/commits.njk CHANGED
@@ -7,6 +7,7 @@ permalink: "/repos/{{ repo.id }}/commits/"
7
7
  layout: layout.njk
8
8
  eleventyComputed:
9
9
  title: "Commits | {{ repo.data.title }}"
10
+ description: "Commit history for {{ repo.data.title }} — {{ repo.data.description }}"
10
11
  ---
11
12
  {% from "repo-layout.njk" import repoLayout %}
12
13
  <div id="repo-page">
src/repos/file-blame.njk CHANGED
@@ -7,6 +7,8 @@ permalink: "/repos/{{ item.repoId }}/files/{{ item.file.path }}/blame/"
7
7
  layout: layout.njk
8
8
  eleventyComputed:
9
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"
10
12
  ---
11
13
  {% from "repo-layout.njk" import repoLayout %}
12
14
  {% from "file-layout.njk" import fileLayout %}
src/repos/file-history.njk CHANGED
@@ -7,6 +7,8 @@ permalink: "/repos/{{ item.repoId }}/files/{{ item.file.path }}/history/"
7
7
  layout: layout.njk
8
8
  eleventyComputed:
9
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"
10
12
  ---
11
13
  {% from "repo-layout.njk" import repoLayout %}
12
14
  {% from "file-layout.njk" import fileLayout %}
src/repos/file.njk CHANGED
@@ -7,6 +7,8 @@ permalink: "/repos/{{ item.repoId }}/files/{{ item.file.path }}/"
7
7
  layout: layout.njk
8
8
  eleventyComputed:
9
9
  title: "{{ item.file.name }} | {{ item.repo.data.title }}"
10
+ description: "{{ item.file.path }} — source file in {{ item.repo.data.title }}"
11
+ robots: "noindex, follow"
10
12
  ---
11
13
  {% from "repo-layout.njk" import repoLayout %}
12
14
  {% from "file-layout.njk" import fileLayout, fileContent %}
src/repos/files-index.njk CHANGED
@@ -7,6 +7,7 @@ permalink: "/repos/{{ repo.id }}/files/"
7
7
  layout: layout.njk
8
8
  eleventyComputed:
9
9
  title: "Code | {{ repo.data.title }}"
10
+ description: "Browse the source files of {{ repo.data.title }} — {{ repo.data.description }}"
10
11
  ---
11
12
  {% from "repo-layout.njk" import repoLayout %}
12
13
  {% from "file-layout.njk" import fileLayout, fileContent %}
src/repos/repo.njk CHANGED
@@ -10,6 +10,23 @@ eleventyComputed:
10
10
  description: "{{ repo.data.description }}"
11
11
  ---
12
12
  {% from "repo-layout.njk" import repoLayout %}
13
+ <script type="application/ld+json">
14
+ {
15
+ "@context": "https://schema.org",
16
+ "@type": "SoftwareSourceCode",
17
+ "name": "{{ repo.data.title }}",
18
+ "description": "{{ repo.data.description }}",
19
+ "codeRepository": "https://git.pyrossh.dev/{{ repo.id }}",
20
+ "url": "{{ site.url }}/repos/{{ repo.id }}/",
21
+ "programmingLanguage": "{{ repo.data.tags[0] }}",
22
+ "keywords": "{{ repo.data.tags | join(', ') }}",
23
+ "author": {
24
+ "@type": "Person",
25
+ "name": "pyrossh",
26
+ "url": "{{ site.url }}"
27
+ }
28
+ }
29
+ </script>
13
30
  <div id="repo-page">
14
31
  {{ repoLayout(repo.id, repo.data.title, repo.data.description, repo.data.tags, "Readme", repo.version) }}
15
32
  <div id="repo-content">
src/robots.njk CHANGED
@@ -4,3 +4,5 @@ eleventyExcludeFromCollections: true
4
4
  ---
5
5
  User-agent: *
6
6
  Allow: /
7
+
8
+ Sitemap: {{ site.url }}/sitemap.xml
tests/pages.test.js CHANGED
@@ -43,6 +43,20 @@ describe("static pages", () => {
43
43
  assert.match(await page.title(), /pyrossh/);
44
44
  });
45
45
 
46
+ test("home page has a search-engine description", async () => {
47
+ await goto("/");
48
+ const description = await page.locator('meta[name="description"]').getAttribute("content");
49
+ assert.match(description, /Tech Lead from Bangalore/);
50
+ assert.equal(
51
+ await page.locator('meta[property="og:description"]').getAttribute("content"),
52
+ description,
53
+ );
54
+
55
+ const jsonLd = JSON.parse(await page.locator('script[type="application/ld+json"]').textContent());
56
+ assert.equal(jsonLd["@type"], "Person");
57
+ assert.equal(jsonLd.description, description);
58
+ });
59
+
46
60
  test("cv page renders", async () => {
47
61
  const response = await goto("/cv/");
48
62
  assert.equal(response.status(), 200);
@@ -63,6 +77,17 @@ describe("static pages", () => {
63
77
  assert.match(await page.title(), /React Powertools/);
64
78
  });
65
79
 
80
+ test("a post page has a unique description and BlogPosting structured data", async () => {
81
+ await goto("/posts/react-powertools-swr/");
82
+ const description = await page.locator('meta[name="description"]').getAttribute("content");
83
+ assert.match(description, /fetch data/);
84
+ assert.equal(await page.locator('meta[property="og:type"]').getAttribute("content"), "article");
85
+
86
+ const jsonLd = JSON.parse(await page.locator('script[type="application/ld+json"]').textContent());
87
+ assert.equal(jsonLd["@type"], "BlogPosting");
88
+ assert.equal(jsonLd.description, description);
89
+ });
90
+
66
91
  test("only-bible-app pages render", async () => {
67
92
  for (const url of ["/only-bible-app/", "/only-bible-app/privacy-policy/", "/only-bible-app/terms-and-conditions/"]) {
68
93
  const response = await goto(url);
@@ -79,6 +104,22 @@ describe("static pages", () => {
79
104
  assert.equal(rss.status(), 200);
80
105
  });
81
106
 
107
+ test("robots.txt points at the sitemap, which lists the home page and repo pages but not thin/noindexed pages", async () => {
108
+ await goto("/robots.txt");
109
+ assert.match(await page.content(), /Sitemap: https:\/\/pyrossh\.dev\/sitemap\.xml/);
110
+
111
+ const sitemap = await goto("/sitemap.xml");
112
+ assert.equal(sitemap.status(), 200);
113
+ const body = await page.content();
114
+ assert.match(body, /<loc>https:\/\/pyrossh\.dev\/<\/loc>/);
115
+ assert.match(body, /<loc>https:\/\/pyrossh\.dev\/repos\/website\/<\/loc>/);
116
+ // Individual commit/file pages are marked noindex (thin, near-duplicate
117
+ // content repeated thousands of times across repos) and must not be
118
+ // submitted for indexing.
119
+ assert.doesNotMatch(body, /\/repos\/website\/commits\/[0-9a-f]{20,}\//);
120
+ assert.doesNotMatch(body, /\/repos\/website\/files\/.*\/(history|blame)\//);
121
+ });
122
+
82
123
  test("unknown route returns the 404 page", async () => {
83
124
  const response = await goto("/this-page-does-not-exist/");
84
125
  assert.equal(response.status(), 404);
@@ -96,6 +137,33 @@ describe("repo pages", () => {
96
137
  assert.equal(await page.locator(".nav a.active-tab").textContent(), "Readme");
97
138
  });
98
139
 
140
+ test("repo readme page has a unique description and SoftwareSourceCode structured data", async () => {
141
+ await goto(`/repos/${repoId}/`);
142
+ const description = await page.locator('meta[name="description"]').getAttribute("content");
143
+ assert.match(description, /astrojs/);
144
+ assert.equal(await page.locator('meta[name="robots"]').getAttribute("content"), "index, follow");
145
+
146
+ const jsonLd = JSON.parse(await page.locator('script[type="application/ld+json"]').textContent());
147
+ assert.equal(jsonLd["@type"], "SoftwareSourceCode");
148
+ assert.equal(jsonLd.description, description);
149
+ });
150
+
151
+ test("commit and file-history/blame pages are noindex to avoid thin/duplicate content", async () => {
152
+ await goto(`/repos/${repoId}/commits/`);
153
+ // Scoped to `.event` to grab an actual commit row, not the repo-layout
154
+ // nav's own "Commits" tab link (which also matches the href prefix and
155
+ // points at this same list page, not a per-commit page).
156
+ const firstCommitHref = await page.locator(`.event a[href^='/repos/${repoId}/commits/']`).first().getAttribute("href");
157
+ await goto(firstCommitHref);
158
+ assert.equal(await page.locator('meta[name="robots"]').getAttribute("content"), "noindex, follow");
159
+
160
+ await goto(`/repos/${repoId}/files/eleventy.config.js/history/`);
161
+ assert.equal(await page.locator('meta[name="robots"]').getAttribute("content"), "noindex, follow");
162
+
163
+ await goto(`/repos/${repoId}/files/eleventy.config.js/blame/`);
164
+ assert.equal(await page.locator('meta[name="robots"]').getAttribute("content"), "noindex, follow");
165
+ });
166
+
99
167
  test("repo commits page lists commits", async () => {
100
168
  const response = await goto(`/repos/${repoId}/commits/`);
101
169
  assert.equal(response.status(), 200);