commit a60862afc5d03328354c19afb196d8676f2ea6c2
parent e0a3c98309692c32174a344bee52ee85d6ec222a
Author: Amit Dutta <mail@amit.is-a.dev>
Date: Mon, 24 Aug 2026 11:08:32 +0530
Merge pull request #63 from notamitgamer/refactor/modularize-md-generator
refactor: modularize md.py into utils/bsc_md/ package
Diffstat:
12 files changed, 606 insertions(+), 563 deletions(-)
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
@@ -82,7 +82,7 @@ jobs:
- name: Generate markdown files
run: |
python list.py
- python md.py
+ python main.py
- name: Generate changelog
env:
diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml
@@ -28,7 +28,7 @@ jobs:
run: python list.py
- name: Run md.py
- run: python md.py
+ run: python main.py
- name: Fail if generators reported errors
run: |
diff --git a/list.py b/list.py
@@ -2,7 +2,7 @@ import os
EXTENSIONS = ('.c', '.r', '.cpp', '.py')
-EXCLUDE = ('list.py', 'md.py', 'utils', 'docs')
+EXCLUDE = ('list.py', 'main.py', 'utils', 'docs')
ALGO_FOLDER_NAME = 'algorithms'
diff --git a/main.py b/main.py
@@ -0,0 +1,11 @@
+"""Entry point for generating VitePress docs from list.txt.
+
+Replaces the old standalone md.py — generation logic now lives in
+utils/bsc_md/. Run this the same way md.py used to be run:
+
+ python main.py
+"""
+from utils.bsc_md.pipeline import run
+
+if __name__ == "__main__":
+ run()
diff --git a/md.py b/md.py
@@ -1,559 +0,0 @@
-import os
-import re
-
-FILES_LIST = "list.txt"
-BSC_ROOT = "."
-DOCS_OUTPUT = "docs"
-
-# Protected paths that should never be overwritten
-PROTECTED_INDEX_FILES = {
- os.path.normpath(os.path.join(DOCS_OUTPUT, f"semester_{i}", "index.md"))
- for i in range(1, 9)
-}
-PROTECTED_INDEX_FILES.add(os.path.normpath(os.path.join(DOCS_OUTPUT, "index.md")))
-
-ALGO_FOLDER_NAME = "algorithms" # folder name that contains algorithm .md files
-
-# Directories excluded from index generation
-IGNORED_FOLDERS = {"stylesheets", "overrides", "assets", ".vitepress", "node_modules", "public"}
-
-# Language config mapped by file extension
-SUPPORTED_LANGS = {
- '.c': {'label': 'C', 'fence': 'c', 'style': 'c'},
- '.cpp': {'label': 'C++', 'fence': 'cpp', 'style': 'c'},
- '.h': {'label': 'C Header', 'fence': 'c', 'style': 'c'},
- '.hpp': {'label': 'C++ Header', 'fence': 'cpp', 'style': 'c'},
- '.r': {'label': 'R', 'fence': 'r', 'style': 'hash'},
- '.py': {'label': 'Python', 'fence': 'python', 'style': 'hash'},
- '.java': {'label': 'Java', 'fence': 'java', 'style': 'c'},
- '.js': {'label': 'JavaScript', 'fence': 'javascript', 'style': 'c'},
- '.ts': {'label': 'TypeScript', 'fence': 'typescript', 'style': 'c'},
- '.sh': {'label': 'Bash', 'fence': 'bash', 'style': 'hash'},
-}
-
-def read_block_comment(lines, start):
- result = []
- i = start
- n = len(lines)
- first = lines[i].strip()
-
- if first.startswith('/*') and '*/' in first:
- inner = first[2: first.index('*/')].strip().strip('*').strip()
- if inner:
- result.append(inner)
- return result, i + 1
-
- inner = first[2:].strip().strip('*').strip()
- if inner:
- result.append(inner)
- i += 1
-
- while i < n:
- line = lines[i].strip()
- if '*/' in line:
- text = line[: line.index('*/')].strip().strip('*').strip()
- if text:
- result.append(text)
- return result, i + 1
- text = line.strip('*').strip()
- if text:
- result.append(text)
- i += 1
-
- return result, i
-
-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:
- """Escape characters that break Vue template compilation in VitePress."""
- return s.replace('&', '&').replace('<', '<').replace('>', '>')
-
-def format_author(author: str) -> str:
- """Return a mailto markdown link if the author string contains an email.
- Handles both 'Name <email>' and 'Name (email)' formats."""
- m = re.search(r'(.*?)\s*[<(]([^<>()]+@[^<>()]+)[>)]', author)
- if m:
- name = esc_html(m.group(1).strip())
- email = esc_html(m.group(2).strip())
- return f"[{name}](mailto:{email})"
- return esc_html(author)
-
-def format_author_html(author: str) -> str:
- """Return an HTML <a> mailto link if the author string contains an email.
- Handles both 'Name <email>' and 'Name (email)' formats."""
- m = re.search(r'(.*?)\s*[<(]([^<>()]+@[^<>()]+)[>)]', author)
- if m:
- name = esc_html(m.group(1).strip())
- email = esc_html(m.group(2).strip())
- return f'<a href="mailto:{email}" style="color:var(--vp-c-text-3);">{name}</a>'
- return esc_html(author)
-
-def parse_c_style(content):
- lines = content.splitlines()
- n = len(lines)
- i = 0
- author = date = repo = license_str = problem_statement = ""
-
- while i < n and not lines[i].strip():
- i += 1
-
- # Extract metadata block
- if i < n and lines[i].strip().startswith('/*'):
- block, i = read_block_comment(lines, i)
- for line in block:
- # Handles both newline-per-field and pipe-separated formats
- parts = [p.strip() for p in line.split('|')]
- for part in parts:
- if ':' in part:
- key, _, val = part.partition(':')
- key = key.strip().lower()
- val = val.strip()
- if 'author' in key: author = val
- elif 'date' in key: date = val
- elif 'repo' in key: repo = val
- elif 'license' in key: license_str = val
-
- while i < n and not lines[i].strip():
- i += 1
-
- # Extract problem statement block (ensures it's not the actual code starting)
- if i < n and lines[i].strip().startswith('/*'):
- peek_block, peek_i = read_block_comment(lines, i)
- block_text = ' '.join(peek_block)
- if '#include' not in block_text and 'import ' not in block_text:
- problem_statement = ' '.join(p for p in peek_block if p).strip()
- i = peek_i
-
- # Locate the beginning of actual source code
- code_start = None
- for j in list(range(i, n)) + list(range(0, i)):
- line_strip = lines[j].strip()
- if line_strip.startswith('#include') or line_strip.startswith('import '):
- code_start = j
- break
-
- code = '\n'.join(lines[code_start:]).strip() if code_start is not None else content.strip()
- return author, date, repo, license_str, problem_statement, code
-
-def parse_hash_style(content):
- lines = content.splitlines()
- n = len(lines)
- i = 0
- author = date = repo = license_str = problem_statement = ""
-
- while i < n and not lines[i].strip():
- i += 1
-
- # Extract metadata block
- meta_lines = []
- while i < n and lines[i].strip().startswith('#'):
- meta_lines.append(lines[i].strip()[1:].strip())
- i += 1
-
- for raw_line in meta_lines:
- for part in raw_line.split('|'):
- if ':' in part:
- key, _, val = part.partition(':')
- key = key.strip().lower()
- val = val.strip()
- if 'author' in key: author = val
- elif 'date' in key: date = val
- elif 'repo' in key: repo = val
- elif 'license' in key: license_str = val
-
- while i < n and not lines[i].strip():
- i += 1
-
- # Extract problem statement block
- ps_lines = []
- while i < n and lines[i].strip().startswith('#'):
- text = lines[i].strip()[1:].strip()
- if text:
- ps_lines.append(text)
- i += 1
-
- if ps_lines:
- problem_statement = ' '.join(ps_lines).strip()
-
- code = content.strip()
- return author, date, repo, license_str, problem_statement, code
-
-
-ALGO_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;" class="lucide lucide-binary"><rect x="14" y="14" width="4" height="6" rx="2"/><rect x="6" y="4" width="4" height="6" rx="2"/><path d="M6 20h4"/><path d="M14 10h4"/><path d="M6 14h2v6"/><path d="M14 4h2v6"/></svg>'
-
-def parse_algo_md(content):
- """Parse a GitHub-style algorithm .md file and convert to VitePress format."""
- lines = content.splitlines()
- title = ""
- problem_statement = ""
- body_lines = []
- i = 0
- n = len(lines)
-
- # Extract title from first # heading
- while i < n:
- line = lines[i]
- if line.startswith("# "):
- title = line[2:].strip()
- i += 1
- break
- i += 1
-
- # Parse rest: find problem statement in > blockquote under ### Problem Statement
- in_problem_section = False
- ps_lines = []
-
- while i < n:
- line = lines[i]
- stripped = line.strip()
-
- if stripped.lower().startswith("### problem statement"):
- in_problem_section = True
- i += 1
- continue
-
- if in_problem_section:
- # Allow blank lines between heading and blockquote
- if stripped == "":
- i += 1
- continue
- if stripped.startswith("> "):
- ps_lines.append(stripped[2:].strip())
- i += 1
- continue
- elif stripped == ">":
- i += 1
- continue
- else:
- # Non-blockquote, non-blank line ends problem section
- in_problem_section = False
- problem_statement = " ".join(ps_lines).strip()
- body_lines.append(line)
- else:
- body_lines.append(line)
-
- i += 1
-
- if in_problem_section and ps_lines:
- problem_statement = " ".join(ps_lines).strip()
-
- return title, problem_statement, body_lines
-
-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}"
-
- fm = [
- "---",
- f"title: '{ALGO_ICON_SVG} {esc_yaml(title)}'",
- f"description: '{esc_yaml(desc)}'",
- f"source: '{source_path}'",
- format_tags_yaml(tags or []),
- "---",
- "",
- ]
-
- body = [f"# {title}", ""]
-
- if problem_statement:
- body += [
- "### Problem Statement",
- "",
- "::: tip Problem Statement",
- esc_html(problem_statement),
- ":::",
- "",
- ]
-
- # Replace > blockquote problem section in body with nothing (already handled above)
- # Just append the remaining body lines (Algorithm, Pseudocode, Complexity etc.)
- skip_next_blockquote = False
- cleaned = []
- blines = body_lines[:]
- j = 0
- while j < len(blines):
- l = blines[j]
- s = l.strip()
- if s.lower().startswith("### problem statement"):
- # skip until blockquote ends
- j += 1
- while j < len(blines) and (blines[j].strip().startswith(">") or blines[j].strip() == ""):
- j += 1
- continue
- cleaned.append(l)
- j += 1
-
- # Remove leading blank lines from cleaned
- while cleaned and not cleaned[0].strip():
- cleaned.pop(0)
-
- body += cleaned
-
- return "\n".join(fm + body)
-
-
-def build_md(filename, lang_label, fence_lang, author, date, repo, license_str,
- 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>'
-
- fm_lines = [
- "---",
- f"title: '{icon_svg} {esc_yaml(filename)}'",
- f"description: '{esc_yaml(desc)}'",
- f"source: '{rel_url}'",
- format_tags_yaml(tags or []),
- "---",
- ]
-
- body = [
- "",
- f"# {filename}",
- ""
- ]
-
- if problem_statement:
- body += [
- "",
- "### Problem Statement",
- "",
- f"::: tip {filename}",
- esc_html(problem_statement),
- ":::",
- "",
- ]
-
- body += [
- "## Source Code",
- "",
- f"```{fence_lang} [{filename}]",
- code,
- "```",
- "---",
- "",
- ]
-
- meta_parts = []
- if author:
- meta_parts.append(f"● Author - {format_author_html(author)}")
- if date:
- meta_parts.append(f"Updated - {esc_html(date)}")
-
- if meta_parts:
- body += [
- f'<div style="font-size:0.8rem;color:var(--vp-c-text-3);margin-bottom:16px;">'
- f'{" · ".join(meta_parts)}</div>',
- "",
- ]
-
- return '\n'.join(fm_lines + body)
-
-def _get_md_title(md_path: str) -> str:
- try:
- with open(md_path, encoding='utf-8') as f:
- content = f.read()
- # Extracts actual title text, ignoring prepended SVGs or icons
- m = re.search(r'^title:\s*\'(?:.*?</svg>\s*)?(.+)\'$', content, re.MULTILINE)
- if m:
- return m.group(1).strip()
- except OSError:
- pass
- return os.path.splitext(os.path.basename(md_path))[0]
-
-def create_folder_indexes(docs_root):
- for root, dirs, files in os.walk(docs_root):
- dirs[:] = [d for d in dirs if d not in IGNORED_FOLDERS]
-
- folder_name = os.path.basename(root)
-
- if folder_name in IGNORED_FOLDERS:
- continue
-
- index_path = os.path.normpath(os.path.join(root, 'index.md'))
-
- if index_path in PROTECTED_INDEX_FILES and os.path.exists(index_path):
- continue
-
- md_files = sorted(
- f for f in files
- if f.endswith('.md') and f.lower() not in {'index.md', 'readme.md', 'default.md', 'home.md', 'tags.md'}
- )
- subdirs = sorted(dirs)
-
- if root == docs_root:
- title = "BSc Code Index"
- intro = "Select a category from the left sidebar or the table below."
- else:
- title = folder_name.replace('_', ' ').replace('-', ' ').title()
- intro = f"Files and sub-folders in **{title}**."
-
- with open(index_path, 'w', encoding='utf-8') as f:
- f.write(f"# {title}\n\n{intro}\n\n")
-
- if subdirs:
- f.write("## Folders\n\n")
- f.write("| # | Folder | Link |\n")
- f.write("|---|---|---|\n")
- for idx, d in enumerate(subdirs, 1):
- dir_title = d.replace('_', ' ').replace('-', ' ').title()
- f.write(f"| {idx} | {dir_title} | [Open]({d}/index.md) |\n")
- f.write("\n")
-
- if md_files:
- f.write("## Files\n\n")
- f.write("| # | File | Link |\n")
- f.write("|---|---|---|\n")
- for idx, md_file in enumerate(md_files, 1):
- src_name = _get_md_title(os.path.join(root, md_file))
- f.write(f"| {idx} | `{src_name}` | [View Code]({md_file}) |\n")
- f.write("\n")
-
-def main():
- with open(FILES_LIST, 'r', encoding='utf-8') as f:
- file_paths = [line.strip() for line in f if line.strip()]
-
- generated = skipped = 0
-
- for full_path in file_paths:
- full_path = os.path.normpath(full_path)
- ext = os.path.splitext(full_path)[1].lower()
-
- # Handle algorithm .md files
- if ext == '.md':
- parent_folder = os.path.basename(os.path.dirname(full_path))
- if parent_folder.lower() != ALGO_FOLDER_NAME:
- print(f"SKIP (non-algo .md): {full_path}")
- skipped += 1
- continue
-
- try:
- with open(full_path, 'r', encoding='utf-8', errors='ignore') as f:
- content_md = f.read()
- except FileNotFoundError:
- print(f"NOT FOUND: {full_path}")
- skipped += 1
- continue
-
- filename_base = os.path.splitext(os.path.basename(full_path))[0]
- rel_path = os.path.relpath(full_path, BSC_ROOT)
- title, problem_statement, body_lines = parse_algo_md(content_md)
- if not title:
- title = filename_base
- rel_url_algo = rel_path.replace('\\', '/')
- 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'
- md_out = os.path.normpath(os.path.join(DOCS_OUTPUT, md_rel))
-
- if md_out in PROTECTED_INDEX_FILES and os.path.exists(md_out):
- print(f"SKIP (Protected Index File): {md_out}")
- skipped += 1
- continue
-
- os.makedirs(os.path.dirname(md_out), exist_ok=True)
- with open(md_out, 'w', encoding='utf-8') as f:
- f.write(md_content)
-
- print(f"OK {md_rel}")
- generated += 1
- continue
-
- if ext not in SUPPORTED_LANGS:
- print(f"SKIP (unsupported extension): {full_path}")
- skipped += 1
- continue
-
- lang_info = SUPPORTED_LANGS[ext]
-
- try:
- rel_path = os.path.relpath(full_path, BSC_ROOT)
- except ValueError:
- print(f"SKIP (relpath failed): {full_path}")
- skipped += 1
- continue
-
- rel_url = rel_path.replace('\\', '/')
-
- try:
- with open(full_path, 'r', encoding='utf-8', errors='ignore') as f:
- content = f.read()
- except FileNotFoundError:
- print(f"NOT FOUND: {full_path}")
- skipped += 1
- continue
-
- filename = os.path.basename(full_path)
-
- if lang_info['style'] == 'c':
- author, date, repo, license_str, problem_statement, code = parse_c_style(content)
- 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, file_tags,
- )
-
- md_rel = os.path.splitext(rel_path)[0] + '.md'
- md_out = os.path.normpath(os.path.join(DOCS_OUTPUT, md_rel))
-
- if md_out in PROTECTED_INDEX_FILES and os.path.exists(md_out):
- print(f"SKIP (Protected Index File): {md_out}")
- skipped += 1
- continue
-
- os.makedirs(os.path.dirname(md_out), exist_ok=True)
- with open(md_out, 'w', encoding='utf-8') as f:
- f.write(md_content)
-
- print(f"OK {md_rel}")
- generated += 1
-
- print(f"\nDone - {generated} generated, {skipped} skipped.")
- print("Generating folder index pages...")
- create_folder_indexes(DOCS_OUTPUT)
- print("Folder indexes created.")
-
-if __name__ == "__main__":
- main()-
\ No newline at end of file
diff --git a/utils/bsc_md/__init__.py b/utils/bsc_md/__init__.py
@@ -0,0 +1,10 @@
+"""bsc_md — markdown generation package for the BSc Code Index docs site.
+
+Modules:
+ config constants (paths, supported languages, icons, protected files)
+ formatting YAML/HTML escaping and author-string helpers
+ parsers source-comment metadata/problem-statement parsers
+ builders turns parsed data into VitePress-ready markdown
+ indexing generates folder index.md pages
+ pipeline orchestrates the full generation run (called from main.py)
+"""
diff --git a/utils/bsc_md/builders.py b/utils/bsc_md/builders.py
@@ -0,0 +1,111 @@
+from .config import ALGO_ICON_SVG, FILE_ICON_SVG
+from .formatting import esc_yaml, esc_html, format_author_html, format_tags_yaml
+
+
+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}"
+
+ fm = [
+ "---",
+ f"title: '{ALGO_ICON_SVG} {esc_yaml(title)}'",
+ f"description: '{esc_yaml(desc)}'",
+ f"source: '{source_path}'",
+ format_tags_yaml(tags or []),
+ "---",
+ "",
+ ]
+
+ body = [f"# {title}", ""]
+
+ if problem_statement:
+ body += [
+ "### Problem Statement",
+ "",
+ "::: tip Problem Statement",
+ esc_html(problem_statement),
+ ":::",
+ "",
+ ]
+
+ # Replace > blockquote problem section in body with nothing (already handled above)
+ # Just append the remaining body lines (Algorithm, Pseudocode, Complexity etc.)
+ cleaned = []
+ blines = body_lines[:]
+ j = 0
+ while j < len(blines):
+ l = blines[j]
+ s = l.strip()
+ if s.lower().startswith("### problem statement"):
+ # skip until blockquote ends
+ j += 1
+ while j < len(blines) and (blines[j].strip().startswith(">") or blines[j].strip() == ""):
+ j += 1
+ continue
+ cleaned.append(l)
+ j += 1
+
+ # Remove leading blank lines from cleaned
+ while cleaned and not cleaned[0].strip():
+ cleaned.pop(0)
+
+ body += cleaned
+
+ return "\n".join(fm + body)
+
+
+def build_md(filename, lang_label, fence_lang, author, date, repo, license_str,
+ problem_statement, code, rel_url, tags=None):
+
+ desc = problem_statement if problem_statement else f"{lang_label} program — {filename}"
+
+ fm_lines = [
+ "---",
+ f"title: '{FILE_ICON_SVG} {esc_yaml(filename)}'",
+ f"description: '{esc_yaml(desc)}'",
+ f"source: '{rel_url}'",
+ format_tags_yaml(tags or []),
+ "---",
+ ]
+
+ body = [
+ "",
+ f"# {filename}",
+ ""
+ ]
+
+ if problem_statement:
+ body += [
+ "",
+ "### Problem Statement",
+ "",
+ f"::: tip {filename}",
+ esc_html(problem_statement),
+ ":::",
+ "",
+ ]
+
+ body += [
+ "## Source Code",
+ "",
+ f"```{fence_lang} [{filename}]",
+ code,
+ "```",
+ "---",
+ "",
+ ]
+
+ meta_parts = []
+ if author:
+ meta_parts.append(f"● Author - {format_author_html(author)}")
+ if date:
+ meta_parts.append(f"Updated - {esc_html(date)}")
+
+ if meta_parts:
+ body += [
+ f'<div style="font-size:0.8rem;color:var(--vp-c-text-3);margin-bottom:16px;">'
+ f'{" · ".join(meta_parts)}</div>',
+ "",
+ ]
+
+ return '\n'.join(fm_lines + body)
diff --git a/utils/bsc_md/config.py b/utils/bsc_md/config.py
@@ -0,0 +1,35 @@
+import os
+
+FILES_LIST = "list.txt"
+BSC_ROOT = "."
+DOCS_OUTPUT = "docs"
+
+ALGO_FOLDER_NAME = "algorithms" # folder name that contains algorithm .md files
+
+# Directories excluded from index generation
+IGNORED_FOLDERS = {"stylesheets", "overrides", "assets", ".vitepress", "node_modules", "public"}
+
+# Language config mapped by file extension
+SUPPORTED_LANGS = {
+ '.c': {'label': 'C', 'fence': 'c', 'style': 'c'},
+ '.cpp': {'label': 'C++', 'fence': 'cpp', 'style': 'c'},
+ '.h': {'label': 'C Header', 'fence': 'c', 'style': 'c'},
+ '.hpp': {'label': 'C++ Header', 'fence': 'cpp', 'style': 'c'},
+ '.r': {'label': 'R', 'fence': 'r', 'style': 'hash'},
+ '.py': {'label': 'Python', 'fence': 'python', 'style': 'hash'},
+ '.java': {'label': 'Java', 'fence': 'java', 'style': 'c'},
+ '.js': {'label': 'JavaScript', 'fence': 'javascript', 'style': 'c'},
+ '.ts': {'label': 'TypeScript', 'fence': 'typescript', 'style': 'c'},
+ '.sh': {'label': 'Bash', 'fence': 'bash', 'style': 'hash'},
+}
+
+ALGO_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;" class="lucide lucide-binary"><rect x="14" y="14" width="4" height="6" rx="2"/><rect x="6" y="4" width="4" height="6" rx="2"/><path d="M6 20h4"/><path d="M14 10h4"/><path d="M6 14h2v6"/><path d="M14 4h2v6"/></svg>'
+
+FILE_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>'
+
+# Protected paths that should never be overwritten
+PROTECTED_INDEX_FILES = {
+ os.path.normpath(os.path.join(DOCS_OUTPUT, f"semester_{i}", "index.md"))
+ for i in range(1, 9)
+}
+PROTECTED_INDEX_FILES.add(os.path.normpath(os.path.join(DOCS_OUTPUT, "index.md")))
diff --git a/utils/bsc_md/formatting.py b/utils/bsc_md/formatting.py
@@ -0,0 +1,67 @@
+import os
+import re
+
+
+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 path relative to BSC_ROOT."""
+ parts = [p for p in os.path.normpath(rel_path).split(os.sep) if p]
+ tags = []
+
+ for part in parts[:-1]:
+ m = re.match(r'^semester[_-]?(\d+)$', part, re.IGNORECASE)
+ tag = f"sem{m.group(1)}" if m else 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:
+ """Escape characters that break Vue template compilation in VitePress."""
+ return s.replace('&', '&').replace('<', '<').replace('>', '>')
+
+
+def format_author(author: str) -> str:
+ """Return a mailto markdown link if the author string contains an email.
+ Handles both 'Name <email>' and 'Name (email)' formats."""
+ m = re.search(r'(.*?)\s*[<(]([^<>()]+@[^<>()]+)[>)]', author)
+ if m:
+ name = esc_html(m.group(1).strip())
+ email = esc_html(m.group(2).strip())
+ return f"[{name}](mailto:{email})"
+ return esc_html(author)
+
+
+def format_author_html(author: str) -> str:
+ """Return an HTML <a> mailto link if the author string contains an email.
+ Handles both 'Name <email>' and 'Name (email)' formats."""
+ m = re.search(r'(.*?)\s*[<(]([^<>()]+@[^<>()]+)[>)]', author)
+ if m:
+ name = esc_html(m.group(1).strip())
+ email = esc_html(m.group(2).strip())
+ return f'<a href="mailto:{email}" style="color:var(--vp-c-text-3);">{name}</a>'
+ return esc_html(author)
diff --git a/utils/bsc_md/indexing.py b/utils/bsc_md/indexing.py
@@ -0,0 +1,66 @@
+import os
+import re
+
+from .config import IGNORED_FOLDERS, PROTECTED_INDEX_FILES
+
+
+def _get_md_title(md_path: str) -> str:
+ try:
+ with open(md_path, encoding='utf-8') as f:
+ content = f.read()
+ # Extracts actual title text, ignoring prepended SVGs or icons
+ m = re.search(r'^title:\s*\'(?:.*?</svg>\s*)?(.+)\'$', content, re.MULTILINE)
+ if m:
+ return m.group(1).strip()
+ except OSError:
+ pass
+ return os.path.splitext(os.path.basename(md_path))[0]
+
+
+def create_folder_indexes(docs_root):
+ for root, dirs, files in os.walk(docs_root):
+ dirs[:] = [d for d in dirs if d not in IGNORED_FOLDERS]
+
+ folder_name = os.path.basename(root)
+
+ if folder_name in IGNORED_FOLDERS:
+ continue
+
+ index_path = os.path.normpath(os.path.join(root, 'index.md'))
+
+ if index_path in PROTECTED_INDEX_FILES and os.path.exists(index_path):
+ continue
+
+ md_files = sorted(
+ f for f in files
+ if f.endswith('.md') and f.lower() not in {'index.md', 'readme.md', 'default.md', 'home.md', 'tags.md'}
+ )
+ subdirs = sorted(dirs)
+
+ if root == docs_root:
+ title = "BSc Code Index"
+ intro = "Select a category from the left sidebar or the table below."
+ else:
+ title = folder_name.replace('_', ' ').replace('-', ' ').title()
+ intro = f"Files and sub-folders in **{title}**."
+
+ with open(index_path, 'w', encoding='utf-8') as f:
+ f.write(f"# {title}\n\n{intro}\n\n")
+
+ if subdirs:
+ f.write("## Folders\n\n")
+ f.write("| # | Folder | Link |\n")
+ f.write("|---|---|---|\n")
+ for idx, d in enumerate(subdirs, 1):
+ dir_title = d.replace('_', ' ').replace('-', ' ').title()
+ f.write(f"| {idx} | {dir_title} | [Open]({d}/index.md) |\n")
+ f.write("\n")
+
+ if md_files:
+ f.write("## Files\n\n")
+ f.write("| # | File | Link |\n")
+ f.write("|---|---|---|\n")
+ for idx, md_file in enumerate(md_files, 1):
+ src_name = _get_md_title(os.path.join(root, md_file))
+ f.write(f"| {idx} | `{src_name}` | [View Code]({md_file}) |\n")
+ f.write("\n")
diff --git a/utils/bsc_md/parsers.py b/utils/bsc_md/parsers.py
@@ -0,0 +1,181 @@
+def read_block_comment(lines, start):
+ result = []
+ i = start
+ n = len(lines)
+ first = lines[i].strip()
+
+ if first.startswith('/*') and '*/' in first:
+ inner = first[2: first.index('*/')].strip().strip('*').strip()
+ if inner:
+ result.append(inner)
+ return result, i + 1
+
+ inner = first[2:].strip().strip('*').strip()
+ if inner:
+ result.append(inner)
+ i += 1
+
+ while i < n:
+ line = lines[i].strip()
+ if '*/' in line:
+ text = line[: line.index('*/')].strip().strip('*').strip()
+ if text:
+ result.append(text)
+ return result, i + 1
+ text = line.strip('*').strip()
+ if text:
+ result.append(text)
+ i += 1
+
+ return result, i
+
+
+def parse_c_style(content):
+ lines = content.splitlines()
+ n = len(lines)
+ i = 0
+ author = date = repo = license_str = problem_statement = ""
+
+ while i < n and not lines[i].strip():
+ i += 1
+
+ # Extract metadata block
+ if i < n and lines[i].strip().startswith('/*'):
+ block, i = read_block_comment(lines, i)
+ for line in block:
+ # Handles both newline-per-field and pipe-separated formats
+ parts = [p.strip() for p in line.split('|')]
+ for part in parts:
+ if ':' in part:
+ key, _, val = part.partition(':')
+ key = key.strip().lower()
+ val = val.strip()
+ if 'author' in key: author = val
+ elif 'date' in key: date = val
+ elif 'repo' in key: repo = val
+ elif 'license' in key: license_str = val
+
+ while i < n and not lines[i].strip():
+ i += 1
+
+ # Extract problem statement block (ensures it's not the actual code starting)
+ if i < n and lines[i].strip().startswith('/*'):
+ peek_block, peek_i = read_block_comment(lines, i)
+ block_text = ' '.join(peek_block)
+ if '#include' not in block_text and 'import ' not in block_text:
+ problem_statement = ' '.join(p for p in peek_block if p).strip()
+ i = peek_i
+
+ # Locate the beginning of actual source code
+ code_start = None
+ for j in list(range(i, n)) + list(range(0, i)):
+ line_strip = lines[j].strip()
+ if line_strip.startswith('#include') or line_strip.startswith('import '):
+ code_start = j
+ break
+
+ code = '\n'.join(lines[code_start:]).strip() if code_start is not None else content.strip()
+ return author, date, repo, license_str, problem_statement, code
+
+
+def parse_hash_style(content):
+ lines = content.splitlines()
+ n = len(lines)
+ i = 0
+ author = date = repo = license_str = problem_statement = ""
+
+ while i < n and not lines[i].strip():
+ i += 1
+
+ # Extract metadata block
+ meta_lines = []
+ while i < n and lines[i].strip().startswith('#'):
+ meta_lines.append(lines[i].strip()[1:].strip())
+ i += 1
+
+ for raw_line in meta_lines:
+ for part in raw_line.split('|'):
+ if ':' in part:
+ key, _, val = part.partition(':')
+ key = key.strip().lower()
+ val = val.strip()
+ if 'author' in key: author = val
+ elif 'date' in key: date = val
+ elif 'repo' in key: repo = val
+ elif 'license' in key: license_str = val
+
+ while i < n and not lines[i].strip():
+ i += 1
+
+ # Extract problem statement block
+ ps_lines = []
+ while i < n and lines[i].strip().startswith('#'):
+ text = lines[i].strip()[1:].strip()
+ if text:
+ ps_lines.append(text)
+ i += 1
+
+ if ps_lines:
+ problem_statement = ' '.join(ps_lines).strip()
+
+ code = content.strip()
+ return author, date, repo, license_str, problem_statement, code
+
+
+def parse_algo_md(content):
+ """Parse a GitHub-style algorithm .md file and convert to VitePress format."""
+ lines = content.splitlines()
+ title = ""
+ problem_statement = ""
+ body_lines = []
+ i = 0
+ n = len(lines)
+
+ # Extract title from first # heading
+ while i < n:
+ line = lines[i]
+ if line.startswith("# "):
+ title = line[2:].strip()
+ i += 1
+ break
+ i += 1
+
+ # Parse rest: find problem statement in > blockquote under ### Problem Statement
+ in_problem_section = False
+ ps_lines = []
+
+ while i < n:
+ line = lines[i]
+ stripped = line.strip()
+
+ if stripped.lower().startswith("### problem statement"):
+ in_problem_section = True
+ i += 1
+ continue
+
+ if in_problem_section:
+ # Allow blank lines between heading and blockquote
+ if stripped == "":
+ i += 1
+ continue
+ if stripped.startswith("> "):
+ ps_lines.append(stripped[2:].strip())
+ i += 1
+ continue
+ elif stripped == ">":
+ i += 1
+ continue
+ else:
+ # Non-blockquote, non-blank line ends problem section
+ in_problem_section = False
+ problem_statement = " ".join(ps_lines).strip()
+ body_lines.append(line)
+ else:
+ body_lines.append(line)
+
+ i += 1
+
+ if in_problem_section and ps_lines:
+ problem_statement = " ".join(ps_lines).strip()
+
+ return title, problem_statement, body_lines
diff --git a/utils/bsc_md/pipeline.py b/utils/bsc_md/pipeline.py
@@ -0,0 +1,122 @@
+import os
+
+from .config import (
+ FILES_LIST, BSC_ROOT, DOCS_OUTPUT,
+ ALGO_FOLDER_NAME, SUPPORTED_LANGS, PROTECTED_INDEX_FILES,
+)
+from .formatting import derive_tags
+from .parsers import parse_c_style, parse_hash_style, parse_algo_md
+from .builders import build_md, build_algo_md
+from .indexing import create_folder_indexes
+
+
+def run():
+ with open(FILES_LIST, 'r', encoding='utf-8') as f:
+ file_paths = [line.strip() for line in f if line.strip()]
+
+ generated = skipped = 0
+
+ for full_path in file_paths:
+ full_path = os.path.normpath(full_path)
+ ext = os.path.splitext(full_path)[1].lower()
+
+ # Handle algorithm .md files
+ if ext == '.md':
+ parent_folder = os.path.basename(os.path.dirname(full_path))
+ if parent_folder.lower() != ALGO_FOLDER_NAME:
+ print(f"SKIP (non-algo .md): {full_path}")
+ skipped += 1
+ continue
+
+ try:
+ with open(full_path, 'r', encoding='utf-8', errors='ignore') as f:
+ content_md = f.read()
+ except FileNotFoundError:
+ print(f"NOT FOUND: {full_path}")
+ skipped += 1
+ continue
+
+ filename_base = os.path.splitext(os.path.basename(full_path))[0]
+ rel_path = os.path.relpath(full_path, BSC_ROOT)
+ title, problem_statement, body_lines = parse_algo_md(content_md)
+ if not title:
+ title = filename_base
+ rel_url_algo = rel_path.replace('\\', '/')
+ 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'
+ md_out = os.path.normpath(os.path.join(DOCS_OUTPUT, md_rel))
+
+ if md_out in PROTECTED_INDEX_FILES and os.path.exists(md_out):
+ print(f"SKIP (Protected Index File): {md_out}")
+ skipped += 1
+ continue
+
+ os.makedirs(os.path.dirname(md_out), exist_ok=True)
+ with open(md_out, 'w', encoding='utf-8') as f:
+ f.write(md_content)
+
+ print(f"OK {md_rel}")
+ generated += 1
+ continue
+
+ if ext not in SUPPORTED_LANGS:
+ print(f"SKIP (unsupported extension): {full_path}")
+ skipped += 1
+ continue
+
+ lang_info = SUPPORTED_LANGS[ext]
+
+ try:
+ rel_path = os.path.relpath(full_path, BSC_ROOT)
+ except ValueError:
+ print(f"SKIP (relpath failed): {full_path}")
+ skipped += 1
+ continue
+
+ rel_url = rel_path.replace('\\', '/')
+
+ try:
+ with open(full_path, 'r', encoding='utf-8', errors='ignore') as f:
+ content = f.read()
+ except FileNotFoundError:
+ print(f"NOT FOUND: {full_path}")
+ skipped += 1
+ continue
+
+ filename = os.path.basename(full_path)
+
+ if lang_info['style'] == 'c':
+ author, date, repo, license_str, problem_statement, code = parse_c_style(content)
+ 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, file_tags,
+ )
+
+ md_rel = os.path.splitext(rel_path)[0] + '.md'
+ md_out = os.path.normpath(os.path.join(DOCS_OUTPUT, md_rel))
+
+ if md_out in PROTECTED_INDEX_FILES and os.path.exists(md_out):
+ print(f"SKIP (Protected Index File): {md_out}")
+ skipped += 1
+ continue
+
+ os.makedirs(os.path.dirname(md_out), exist_ok=True)
+ with open(md_out, 'w', encoding='utf-8') as f:
+ f.write(md_content)
+
+ print(f"OK {md_rel}")
+ generated += 1
+
+ print(f"\nDone - {generated} generated, {skipped} skipped.")
+ print("Generating folder index pages...")
+ create_folder_indexes(DOCS_OUTPUT)
+ print("Folder indexes created.")