commit 214e353c0db735f0100a1e4682848555162ec61b
parent 346bd8713f6ddafc6a5144e7f8bf70f56f310329
Author: Amit Dutta <mail@amit.is-a.dev>
Date: Mon, 24 Aug 2026 15:49:21 +0530
Merge pull request #69 from notamitgamer/feat/related-pages-and-sidebar-tags
feat: related pages by tag + auto-populated sidebar tag list
Diffstat:
4 files changed, 179 insertions(+), 2 deletions(-)
diff --git a/docs/.vitepress/theme/components/Labels.vue b/docs/.vitepress/theme/components/Labels.vue
@@ -32,7 +32,7 @@ function humanize(tag) {
display: flex;
flex-wrap: wrap;
gap: 10px;
- margin-top: 20px;
+ margin: 20px 0 24px;
}
.bsc-tag {
diff --git a/docs/.vitepress/theme/components/RelatedPages.vue b/docs/.vitepress/theme/components/RelatedPages.vue
@@ -0,0 +1,85 @@
+<script setup>
+import { computed } from 'vue'
+import { useData, useRoute } from 'vitepress'
+import { data as tagsData } from '../../tags.data.ts'
+
+const MAX_RELATED = 6
+
+const { frontmatter } = useData()
+const route = useRoute()
+
+function stripTitleIcon(title) {
+ if (!title) return title
+ return title.replace(/<svg[\s\S]*?<\/svg>\s*/i, '').trim()
+}
+
+const related = computed(() => {
+ const tags = frontmatter.value.tags
+ if (!Array.isArray(tags) || tags.length === 0) return []
+
+ const currentPath = route.path
+
+ const seen = new Map()
+
+ for (const tag of tags) {
+ const pages = tagsData[tag]
+ if (!pages) continue
+ for (const p of pages) {
+ if (seen.has(p.url)) continue
+ if (p.url === currentPath) continue
+ seen.set(p.url, { title: stripTitleIcon(p.title), url: p.url, tag })
+ }
+ }
+
+ return Array.from(seen.values()).slice(0, MAX_RELATED)
+})
+</script>
+
+<template>
+ <div v-if="related.length" class="bsc-related">
+ <h3 class="bsc-related-heading">Related pages</h3>
+ <ul class="bsc-related-list">
+ <li v-for="item in related" :key="item.url">
+ <a :href="item.url">{{ item.title }}</a>
+ </li>
+ </ul>
+ </div>
+</template>
+
+<style scoped>
+.bsc-related {
+ margin-top: 24px;
+ margin-bottom: 24px;
+ padding-top: 16px;
+ border-top: 1px solid var(--vp-c-divider);
+}
+
+.bsc-related-heading {
+ margin: 0 0 8px;
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--vp-c-text-2);
+ border: none;
+ padding: 0;
+}
+
+.bsc-related-list {
+ margin: 0;
+ padding-left: 0;
+ list-style: none;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.bsc-related-list a {
+ font-size: 14px;
+ color: var(--vp-c-text-1);
+ text-decoration: none;
+}
+
+.bsc-related-list a:hover {
+ color: var(--vp-c-brand-1);
+ text-decoration: underline;
+}
+</style>
diff --git a/docs/.vitepress/theme/components/SidebarTags.vue b/docs/.vitepress/theme/components/SidebarTags.vue
@@ -0,0 +1,88 @@
+<script setup>
+import { computed } from 'vue'
+import { data as tagsData } from '../../tags.data.ts'
+
+const MAX_SHOWN = 12
+
+function humanize(tag) {
+ return tag.replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
+}
+
+const topTags = computed(() => {
+ return Object.entries(tagsData)
+ .map(([tag, pages]) => ({ tag, count: pages.length }))
+ .sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag))
+ .slice(0, MAX_SHOWN)
+})
+
+const hasMore = computed(() => Object.keys(tagsData).length > MAX_SHOWN)
+</script>
+
+<template>
+ <div v-if="topTags.length" class="bsc-sidebar-tags">
+ <p class="bsc-sidebar-tags-heading">Tags</p>
+ <div class="bsc-sidebar-tags-list">
+ <a
+ v-for="t in topTags"
+ :key="t.tag"
+ class="bsc-sidebar-tag"
+ :href="`/tags#${t.tag}`"
+ >{{ humanize(t.tag) }}</a>
+ </div>
+ <a v-if="hasMore" class="bsc-sidebar-tags-more" href="/tags">View all tags →</a>
+ </div>
+</template>
+
+<style scoped>
+.bsc-sidebar-tags {
+ margin-top: 16px;
+ padding: 12px 0 0;
+ border-top: 1px solid var(--vp-c-divider);
+}
+
+.bsc-sidebar-tags-heading {
+ margin: 0 0 8px;
+ padding: 0 24px;
+ font-size: 12px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.02em;
+ color: var(--vp-c-text-2);
+}
+
+.bsc-sidebar-tags-list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ padding: 0 24px;
+}
+
+.bsc-sidebar-tag {
+ font-size: 12px;
+ padding: 3px 8px;
+ color: var(--vp-c-text-2);
+ background: var(--vp-c-bg-soft);
+ border: 1px solid var(--vp-c-divider);
+ border-radius: 4px;
+ text-decoration: none;
+ transition: color 0.15s ease, border-color 0.15s ease;
+}
+
+.bsc-sidebar-tag:hover {
+ color: var(--vp-c-brand-1);
+ border-color: var(--vp-c-brand-1);
+}
+
+.bsc-sidebar-tags-more {
+ display: block;
+ margin-top: 8px;
+ padding: 0 24px;
+ font-size: 12px;
+ color: var(--vp-c-brand-1);
+ text-decoration: none;
+}
+
+.bsc-sidebar-tags-more:hover {
+ text-decoration: underline;
+}
+</style>
diff --git a/docs/.vitepress/theme/index.ts b/docs/.vitepress/theme/index.ts
@@ -9,6 +9,8 @@ import CopyLinkButton from './components/CopyLinkButton.vue'
import MarkdownMenu from './components/MarkdownMenu.vue'
import Breadcrumbs from './components/Breadcrumbs.vue'
import Labels from './components/Labels.vue'
+import RelatedPages from './components/RelatedPages.vue'
+import SidebarTags from './components/SidebarTags.vue'
import './style.css'
export default {
@@ -26,9 +28,11 @@ export default {
]),
'doc-footer-before': () => h(Fragment, [
h(MarkdownMenu),
- h(Labels)
+ h(Labels),
+ h(RelatedPages)
]),
'doc-after': () => h(Footer),
+ 'sidebar-nav-after': () => h(SidebarTags),
'nav-bar-content-after': () => h(SponsorButton)
})
},