bsc

Comprehensive codebase and cou...
Log | Files | Refs | Activity | README | LICENSE

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:
M.github/workflows/main.yml | 2+-
M.github/workflows/pr-check.yml | 2+-
Mlist.py | 2+-
Amain.py | 11+++++++++++
Dmd.py | 560-------------------------------------------------------------------------------
Autils/bsc_md/__init__.py | 10++++++++++
Autils/bsc_md/builders.py | 111+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Autils/bsc_md/config.py | 35+++++++++++++++++++++++++++++++++++
Autils/bsc_md/formatting.py | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Autils/bsc_md/indexing.py | 66++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Autils/bsc_md/parsers.py | 181+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Autils/bsc_md/pipeline.py | 122+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;') - -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('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;') + + +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.")
© notamitgamer • Site Built: 2026-09-05 01:53:16 UTC • git-mirror commit: c170d72 [view raw info]
Originally created with stagit • modified by notamitgamer
Forked from github.com/notamitgamer/git-mirror