website
git clone https://git.pyrossh.dev/website
木 Personal website of pyrossh. Built with astrojs, shiki, vite.
9abd005
— pyrossh
2026-09-08T17:05:42+05:30
Improve SEO for homepage, repo pages, and posts
- eleventy.config.js +19 -1
- src/_includes/layout.njk +10 -4
- src/_includes/post.njk +18 -0
- src/index.njk +15 -0
- src/repos/commit.njk +2 -0
- src/repos/commits.njk +1 -0
- src/repos/file-blame.njk +2 -0
- src/repos/file-history.njk +2 -0
- src/repos/file.njk +2 -0
- src/repos/files-index.njk +1 -0
- src/repos/repo.njk +17 -0
- src/robots.njk +2 -0
- tests/pages.test.js +68 -0
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>{{
|
|
18
|
+
<title>{{ pageTitle }}</title>
|
|
19
|
+
<meta name="robots" content="{{ robots or 'index, follow' }}" />
|
|
18
|
-
<meta name="title" content="{{
|
|
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="{{
|
|
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);
|