commit 06f3484d846895709a713493bcc563bb92d09578
parent 67c149dd0f88197390f5cf6116c9239dcc17baea
Author: Amit Dutta <mail@amit.is-a.dev>
Date: Mon, 24 Aug 2026 10:19:43 +0530
Merge pull request #62 from notamitgamer/feat/tag-labels
feat: add tag/label system for pages
Diffstat:
6 files changed, 287 insertions(+), 7 deletions(-)
diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts
@@ -73,6 +73,7 @@ const vitePressConfig = {
{ text: 'Semester 8', link: '/semester_8/' },
]
},
+ { text: 'Tags', link: '/tags' },
{ text: 'License', link: 'https://github.com/notamitgamer/bsc/blob/main/LICENSE'},
{ text: 'Changelog', link: '/changelog' },
],
diff --git a/docs/.vitepress/tags.data.ts b/docs/.vitepress/tags.data.ts
@@ -0,0 +1,46 @@
+import { createContentLoader } from 'vitepress'
+
+export interface TaggedPage {
+ title: string
+ url: string
+}
+
+export interface TagsData {
+ [tag: string]: TaggedPage[]
+}
+
+declare const data: TagsData
+export { data }
+
+function stripTitleIcon(title: string): string {
+ if (!title) return title
+ return title.replace(/<svg[\s\S]*?<\/svg>\s*/i, '').trim()
+}
+
+export default createContentLoader('**/*.md', {
+ includeSrc: false,
+ render: false,
+ excerpt: false,
+ transform(raw) {
+ const byTag: TagsData = {}
+
+ for (const page of raw) {
+ const tags = page.frontmatter?.tags
+ if (!Array.isArray(tags) || tags.length === 0) continue
+
+ const title = stripTitleIcon(page.frontmatter?.title || page.url)
+
+ for (const tag of tags) {
+ if (!tag) continue
+ if (!byTag[tag]) byTag[tag] = []
+ byTag[tag].push({ title, url: page.url })
+ }
+ }
+
+ for (const tag in byTag) {
+ byTag[tag].sort((a, b) => a.title.localeCompare(b.title))
+ }
+
+ return byTag
+ }
+})
diff --git a/docs/.vitepress/theme/components/Labels.vue b/docs/.vitepress/theme/components/Labels.vue
@@ -0,0 +1,77 @@
+<script setup>
+import { computed } from 'vue'
+import { useData } from 'vitepress'
+
+const { frontmatter } = useData()
+
+const tags = computed(() => {
+ const raw = frontmatter.value.tags
+ if (!raw) return []
+ return Array.isArray(raw) ? raw.filter(Boolean) : [raw]
+})
+
+function humanize(tag) {
+ return tag.replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
+}
+</script>
+
+<template>
+ <div v-if="tags.length" class="bsc-labels" aria-label="Page tags">
+ <a
+ v-for="tag in tags"
+ :key="tag"
+ class="bsc-tag"
+ :href="`/tags#${tag}`"
+ :title="`Browse pages tagged ${humanize(tag)}`"
+ >{{ humanize(tag) }}</a>
+ </div>
+</template>
+
+<style scoped>
+.bsc-labels {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+ margin-bottom: 16px;
+}
+
+.bsc-tag {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ height: 26px;
+ padding: 0 12px 0 20px;
+ font-size: 12.5px;
+ font-weight: 500;
+ line-height: 1;
+ text-decoration: none;
+ white-space: nowrap;
+ color: var(--vp-c-brand-1);
+ background: var(--vp-c-bg-soft);
+ border: 1px solid var(--vp-c-divider);
+ border-left: none;
+ clip-path: polygon(11px 0, 100% 0, 100% 100%, 11px 100%, 0 50%);
+ transition: color 0.15s ease, background 0.15s ease, border-color 0.15s ease;
+}
+
+.bsc-tag::before {
+ content: '';
+ position: absolute;
+ left: 6px;
+ top: 50%;
+ transform: translateY(-50%);
+ width: 4px;
+ height: 4px;
+ border-radius: 50%;
+ background: var(--vp-c-text-3);
+}
+
+.bsc-tag:hover {
+ color: #ffffff;
+ background: var(--vp-c-brand-1);
+}
+
+.bsc-tag:hover::before {
+ background: #ffffff;
+}
+</style>
diff --git a/docs/.vitepress/theme/index.ts b/docs/.vitepress/theme/index.ts
@@ -8,6 +8,7 @@ import ExternalLinkWarning from './components/ExternalLinkWarning.vue'
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 './style.css'
export default {
@@ -19,9 +20,12 @@ export default {
h(Banner),
h(ExternalLinkWarning)
]),
- 'doc-before': () => h('div', { class: 'bsc-doc-toolbar' }, [
- h(Breadcrumbs),
- h(CopyLinkButton)
+ 'doc-before': () => h(Fragment, [
+ h(Labels),
+ h('div', { class: 'bsc-doc-toolbar' }, [
+ h(Breadcrumbs),
+ h(CopyLinkButton)
+ ])
]),
'doc-footer-before': () => h(MarkdownMenu),
'doc-after': () => h(Footer),
diff --git a/docs/tags.md b/docs/tags.md
@@ -0,0 +1,113 @@
+---
+title: Tags
+description: Browse every page by tag.
+---
+
+<script setup>
+import { data as tagsData } from './.vitepress/tags.data.ts'
+
+const tagNames = Object.keys(tagsData).sort()
+
+function humanize(tag) {
+ return tag.replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
+}
+</script>
+
+# Tags
+
+Browse pages grouped by tag. Click any tag chip across the site to jump here.
+
+<div v-if="!tagNames.length" class="bsc-tags-empty">
+ No tags found yet.
+</div>
+
+<div v-for="tag in tagNames" :key="tag" :id="tag" class="bsc-tag-section">
+ <h2 class="bsc-tag-heading">
+ <a class="bsc-tag" :href="`#${tag}`">{{ humanize(tag) }}</a>
+ <span class="bsc-tag-count">{{ tagsData[tag].length }}</span>
+ </h2>
+ <ul class="bsc-tag-pages">
+ <li v-for="page in tagsData[tag]" :key="page.url">
+ <a :href="page.url">{{ page.title }}</a>
+ </li>
+ </ul>
+</div>
+
+<style scoped>
+.bsc-tags-empty {
+ color: var(--vp-c-text-2);
+ font-size: 14px;
+}
+
+.bsc-tag-section {
+ margin: 28px 0;
+ padding-top: 8px;
+ border-top: 1px solid var(--vp-c-divider);
+}
+
+.bsc-tag-heading {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin: 16px 0 10px;
+ border: none;
+ padding: 0;
+}
+
+.bsc-tag-count {
+ font-size: 12px;
+ color: var(--vp-c-text-3);
+ font-weight: 400;
+}
+
+.bsc-tag {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ height: 30px;
+ padding: 0 14px 0 22px;
+ font-size: 14px;
+ font-weight: 500;
+ line-height: 1;
+ text-decoration: none;
+ white-space: nowrap;
+ color: var(--vp-c-brand-1);
+ background: var(--vp-c-bg-soft);
+ border: 1px solid var(--vp-c-divider);
+ border-left: none;
+ clip-path: polygon(12px 0, 100% 0, 100% 100%, 12px 100%, 0 50%);
+}
+
+.bsc-tag::before {
+ content: '';
+ position: absolute;
+ left: 7px;
+ top: 50%;
+ transform: translateY(-50%);
+ width: 4px;
+ height: 4px;
+ border-radius: 50%;
+ background: var(--vp-c-text-3);
+}
+
+.bsc-tag-pages {
+ margin: 0;
+ padding-left: 4px;
+ list-style: none;
+}
+
+.bsc-tag-pages li {
+ padding: 4px 0;
+}
+
+.bsc-tag-pages a {
+ color: var(--vp-c-text-1);
+ text-decoration: none;
+ font-size: 14px;
+}
+
+.bsc-tag-pages a:hover {
+ color: var(--vp-c-brand-1);
+ text-decoration: underline;
+}
+</style>
diff --git a/md.py b/md.py
@@ -64,6 +64,41 @@ def read_block_comment(lines, start):
def esc_yaml(s: str) -> str:
return s.replace("'", "''")
+
+def slugify_tag(s: str) -> str:
+ """Turn a folder/lang name into a clean tag slug."""
+ s = s.strip().lower()
+ s = re.sub(r'[_\s]+', '-', s)
+ s = re.sub(r'[^a-z0-9\-]', '', s)
+ return re.sub(r'-+', '-', s).strip('-')
+
+def derive_tags(rel_path: str, lang_label: str = "") -> list:
+ """Derive tag slugs from a file's path relative to BSC_ROOT.
+ e.g. semester_2/algorithms/foo.c -> ['sem2', 'algorithms', 'c']"""
+ parts = [p for p in os.path.normpath(rel_path).split(os.sep) if p]
+ tags = []
+
+ for part in parts[:-1]: # exclude the filename itself
+ m = re.match(r'^semester[_-]?(\d+)$', part, re.IGNORECASE)
+ if m:
+ tag = f"sem{m.group(1)}"
+ else:
+ tag = slugify_tag(part)
+ if tag and tag not in tags:
+ tags.append(tag)
+
+ if lang_label:
+ lang_tag = slugify_tag(lang_label)
+ if lang_tag and lang_tag not in tags:
+ tags.append(lang_tag)
+
+ return tags
+
+def format_tags_yaml(tags: list) -> str:
+ if not tags:
+ return "tags: []"
+ items = ", ".join(f"'{esc_yaml(t)}'" for t in tags)
+ return f"tags: [{items}]"
def esc_html(s: str) -> str:
@@ -241,7 +276,7 @@ def parse_algo_md(content):
return title, problem_statement, body_lines
-def build_algo_md(filename_base, title, problem_statement, body_lines, source_path=""):
+def build_algo_md(filename_base, title, problem_statement, body_lines, source_path="", tags=None):
"""Build VitePress .md from parsed algo content."""
desc = problem_statement if problem_statement else f"Algorithm — {title}"
@@ -250,6 +285,7 @@ def build_algo_md(filename_base, title, problem_statement, body_lines, source_pa
f"title: '{ALGO_ICON_SVG} {esc_yaml(title)}'",
f"description: '{esc_yaml(desc)}'",
f"source: '{source_path}'",
+ format_tags_yaml(tags or []),
"---",
"",
]
@@ -294,7 +330,7 @@ def build_algo_md(filename_base, title, problem_statement, body_lines, source_pa
def build_md(filename, lang_label, fence_lang, author, date, repo, license_str,
- problem_statement, code, rel_url):
+ problem_statement, code, rel_url, tags=None):
desc = problem_statement if problem_statement else f"{lang_label} program — {filename}"
icon_svg = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline; margin-bottom:-2px; margin-right:6px;"><path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/><path d="M14 2v5a1 1 0 0 0 1 1h5"/><path d="M10 12a1 1 0 0 0-1 1v1a1 1 0 0 1-1 1 1 1 0 0 1 1 1v1a1 1 0 0 0 1 1"/><path d="M14 18a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1 1 1 0 0 1-1-1v-1a1 1 0 0 0-1-1"/></svg>'
@@ -304,6 +340,7 @@ def build_md(filename, lang_label, fence_lang, author, date, repo, license_str,
f"title: '{icon_svg} {esc_yaml(filename)}'",
f"description: '{esc_yaml(desc)}'",
f"source: '{rel_url}'",
+ format_tags_yaml(tags or []),
"---",
]
@@ -441,7 +478,8 @@ def main():
if not title:
title = filename_base
rel_url_algo = rel_path.replace('\\', '/')
- md_content = build_algo_md(filename_base, title, problem_statement, body_lines, rel_url_algo)
+ algo_tags = derive_tags(rel_path)
+ md_content = build_algo_md(filename_base, title, problem_statement, body_lines, rel_url_algo, algo_tags)
rel_path = os.path.relpath(full_path, BSC_ROOT)
md_rel = os.path.splitext(rel_path)[0] + '.md'
@@ -491,9 +529,10 @@ def main():
else:
author, date, repo, license_str, problem_statement, code = parse_hash_style(content)
+ file_tags = derive_tags(rel_path, lang_info['label'])
md_content = build_md(
filename, lang_info['label'], lang_info['fence'], author, date, repo, license_str,
- problem_statement, code, rel_url,
+ problem_statement, code, rel_url, file_tags,
)
md_rel = os.path.splitext(rel_path)[0] + '.md'