#!/usr/bin/env python3 """Generate nexus-docs.html from template.html + ALL docs/ files. Usage: python build_html.py Output: docs/nexus-docs.html (single self-contained HTML file) Collects: .md .json .js .py .sh .txt .yaml .yml .toml .cfg .ini .conf .env .sql Excludes: build_html.py, template.html, nexus-docs.html, binary files """ import os import json DOCS_DIR = os.path.dirname(os.path.abspath(__file__)) TEMPLATE = os.path.join(DOCS_DIR, "template.html") OUTPUT = os.path.join(DOCS_DIR, "nexus-docs.html") # File extensions to include (text/documentation files) INCLUDE_EXT = { '.md', '.markdown', '.json', '.jsonl', '.js', '.ts', '.mjs', '.py', '.sh', '.bash', '.txt', '.csv', '.yaml', '.yml', '.toml', '.cfg', '.ini', '.conf', '.env', '.example', '.sql', '.xml', '.html', '.css', '.gitignore', } # Files to always exclude (by basename) EXCLUDE_FILES = { 'build_html.py', 'template.html', 'nexus-docs.html', } # Directories to skip SKIP_DIRS = {'.git', '__pycache__', 'node_modules', '.venv', 'venv'} # Group config: (directory_match, display_name, icon) GROUP_CONFIG = [ ("project", "Project", "📋"), ("design", "Design", "🎨"), ("changelog", "Changelog", "📝"), ("reports", "Reports", "📊"), ("team", "Team", "👥"), ("memory", "Memory", "🧠"), ("research", "Research", "🔬"), ("skills", "Skills", "⚡"), ] # Fallback for unmatched dirs FALLBACK_ICON = "📄" def collect_all_files(): """Walk docs/ and return list of (relative_path, full_path) sorted. Includes all text doc types.""" results = [] for root, dirs, files in os.walk(DOCS_DIR): dirs[:] = sorted(d for d in dirs if d not in SKIP_DIRS) for f in sorted(files): if f in EXCLUDE_FILES: continue ext = os.path.splitext(f)[1].lower() if ext in INCLUDE_EXT: full = os.path.join(root, f) rel = os.path.relpath(full, DOCS_DIR).replace('\\', '/') results.append((rel, full)) return results def group_files(files): """Group files by top-level directory using GROUP_CONFIG.""" grouped = {} for rel, full in files: top = rel.split('/')[0] if '/' in rel else "Root" # Match against config matched = False for dir_key, display_name, icon in GROUP_CONFIG: if top == dir_key or top.startswith(dir_key): key = display_name if key not in grouped: grouped[key] = {"icon": icon, "files": []} grouped[key]["files"].append((rel, full)) matched = True break if not matched: if top not in grouped: # Find icon from config if partial match icon = FALLBACK_ICON for dir_key, _, ic in GROUP_CONFIG: if dir_key in top: icon = ic break grouped[top] = {"icon": icon, "files": []} grouped[top]["files"].append((rel, full)) # Sort by GROUP_CONFIG order, then alphabetically for rest ordered = [] for _, display_name, _ in GROUP_CONFIG: if display_name in grouped: ordered.append((display_name, grouped.pop(display_name))) for k in sorted(grouped.keys()): ordered.append((k, grouped[k])) return ordered def read_file(path): try: with open(path, 'r', encoding='utf-8') as f: return f.read() except Exception: return "*Unable to read file*" def build_data(groups): """Build NAV_GROUPS and CONTENT_MAP JS objects.""" nav_groups = [] content_map = {} for name, info in groups: files = [] for rel, full in info["files"]: # Show full filename as label (with extension for non-md files) label = rel.split('/')[-1] # Remove .md extension only for markdown files if label.endswith('.md'): label = label[:-3] files.append({"path": rel, "label": label}) # For non-md files, wrap content in a code block for proper rendering raw = read_file(full) ext = os.path.splitext(rel)[1].lower() if ext == '.md': content_map[rel] = raw else: lang = ext.lstrip('.') content_map[rel] = f"# `{rel}`\n\n```{lang}\n{raw}\n```" nav_groups.append({ "name": name, "icon": info["icon"], "collapsed": name == "Skills", # Skills collapsed by default "files": files, }) return nav_groups, content_map def generate(nav_groups, content_map): """Read template, inject data, return final HTML.""" with open(TEMPLATE, 'r', encoding='utf-8') as f: template = f.read() nav_json = json.dumps(nav_groups, ensure_ascii=False, indent=None) # content_map can be huge — compact serialization content_json = json.dumps(content_map, ensure_ascii=False, indent=None) # Inject data before the docApp() function data_script = f""" """ # Insert data script right before the docApp function script placeholder = "// __DATA_PLACEHOLDER__ will be replaced by build_html.py" if placeholder in template: html = template.replace(placeholder, f"// Data injected by build_html.py\nconst NAV_GROUPS = {nav_json};\nconst CONTENT_MAP = {content_json};") else: # Fallback: insert before closing html = template.replace("", data_script + "") return html def main(): import sys sys.stdout.reconfigure(encoding='utf-8') print("Scanning docs/...") files = collect_all_files() md_count = sum(1 for f in files if f[0].endswith('.md')) other_count = len(files) - md_count print(f" Found {md_count} markdown + {other_count} other = {len(files)} total files") print("Grouping...") groups = group_files(files) for name, info in groups: print(f" {info['icon']} {name}: {len(info['files'])} files") print("Reading content & building data...") nav_groups, content_map = build_data(groups) print("Generating HTML...") html = generate(nav_groups, content_map) with open(OUTPUT, 'w', encoding='utf-8') as f: f.write(html) size_mb = os.path.getsize(OUTPUT) / 1024 / 1024 print(f"\nDone: {OUTPUT}") print(f"Size: {size_mb:.1f} MB") if __name__ == '__main__': main()