#!/usr/bin/env python3 """ Docsify-Style HTML Manual Assembly Script Template Generates interactive single-file documentation with hierarchical navigation Usage: 1. Copy this script to your manual output directory 2. Customize MANUAL_META and NAV_STRUCTURE 3. Run: python assemble_docsify.py """ import json import base64 import re from pathlib import Path from typing import Dict, List, Any # Try to import markdown library try: import markdown from markdown.extensions.codehilite import CodeHiliteExtension from markdown.extensions.fenced_code import FencedCodeExtension from markdown.extensions.tables import TableExtension from markdown.extensions.toc import TocExtension HAS_MARKDOWN = True except ImportError: HAS_MARKDOWN = False print("Warning: markdown library not found. Install with: pip install markdown pygments") # ============================================================ # CONFIGURATION - Customize these for your project # ============================================================ # Paths - Update these paths for your environment BASE_DIR = Path(__file__).parent SECTIONS_DIR = BASE_DIR / "sections" SCREENSHOTS_DIR = BASE_DIR / "screenshots" # Template paths - Point to skill templates directory SKILL_DIR = Path(__file__).parent.parent # Adjust based on where script is placed TEMPLATE_FILE = SKILL_DIR / "templates" / "docsify-shell.html" CSS_BASE_FILE = SKILL_DIR / "templates" / "css" / "docsify-base.css" # Manual metadata - Customize for your software MANUAL_META = { "title": "Your Software", "subtitle": "使用手册", "version": "v1.0.0", "timestamp": "2025-01-01", "language": "zh-CN", "logo_icon": "Y" # First letter or emoji } # Output file OUTPUT_FILE = BASE_DIR / f"{MANUAL_META['title']}{MANUAL_META['subtitle']}.html" # Hierarchical navigation structure # Customize groups and items based on your sections NAV_STRUCTURE = [ { "type": "group", "title": "入门指南", "icon": "📚", "expanded": True, "items": [ {"id": "overview", "title": "产品概述", "file": "section-overview.md"}, ] }, { "type": "group", "title": "使用教程", "icon": "🎯", "expanded": False, "items": [ {"id": "ui-guide", "title": "UI操作指南", "file": "section-ui-guide.md"}, ] }, { "type": "group", "title": "API参考", "icon": "🔧", "expanded": False, "items": [ {"id": "api-reference", "title": "API文档", "file": "section-api-reference.md"}, ] }, { "type": "group", "title": "配置与部署", "icon": "⚙️", "expanded": False, "items": [ {"id": "configuration", "title": "配置指南", "file": "section-configuration.md"}, ] }, { "type": "group", "title": "帮助与支持", "icon": "💡", "expanded": False, "items": [ {"id": "troubleshooting", "title": "故障排除", "file": "section-troubleshooting.md"}, {"id": "examples", "title": "代码示例", "file": "section-examples.md"}, ] } ] # Screenshot ID to filename mapping - Customize for your screenshots SCREENSHOT_MAPPING = { # "截图ID": "filename.png", } # ============================================================ # CORE FUNCTIONS - Generally don't need to modify # ============================================================ # Global cache for embedded images _embedded_images = {} def read_file(filepath: Path) -> str: """Read file content with UTF-8 encoding""" return filepath.read_text(encoding='utf-8') # ============================================================ # MERMAID VALIDATION # ============================================================ # Valid Mermaid diagram types MERMAID_DIAGRAM_TYPES = [ 'graph', 'flowchart', 'sequenceDiagram', 'classDiagram', 'stateDiagram', 'stateDiagram-v2', 'erDiagram', 'journey', 'gantt', 'pie', 'quadrantChart', 'requirementDiagram', 'gitGraph', 'mindmap', 'timeline', 'zenuml', 'sankey-beta', 'xychart-beta', 'block-beta' ] # Common Mermaid syntax patterns MERMAID_PATTERNS = { 'graph': r'^graph\s+(TB|BT|LR|RL|TD)\s*$', 'flowchart': r'^flowchart\s+(TB|BT|LR|RL|TD)\s*$', 'sequenceDiagram': r'^sequenceDiagram\s*$', 'classDiagram': r'^classDiagram\s*$', 'stateDiagram': r'^stateDiagram(-v2)?\s*$', 'erDiagram': r'^erDiagram\s*$', 'gantt': r'^gantt\s*$', 'pie': r'^pie\s*(showData|title\s+.*)?\s*$', 'journey': r'^journey\s*$', } class MermaidBlock: """Represents a mermaid code block found in markdown""" def __init__(self, content: str, file: str, line_num: int, indented: bool = False): self.content = content self.file = file self.line_num = line_num self.indented = indented self.errors: List[str] = [] self.warnings: List[str] = [] self.diagram_type: str = None def __repr__(self): return f"MermaidBlock({self.diagram_type}, {self.file}:{self.line_num})" def extract_mermaid_blocks(markdown_text: str, filename: str) -> List[MermaidBlock]: """Extract all mermaid code blocks from markdown text""" blocks = [] # More flexible pattern - matches opening fence with optional indent, # then captures content until closing fence (with any indent) pattern = r'^(\s*)(```|~~~)mermaid\s*\n(.*?)\n\s*\2\s*$' for match in re.finditer(pattern, markdown_text, re.MULTILINE | re.DOTALL): indent = match.group(1) content = match.group(3) # Calculate line number line_num = markdown_text[:match.start()].count('\n') + 1 indented = len(indent) > 0 block = MermaidBlock( content=content, file=filename, line_num=line_num, indented=indented ) blocks.append(block) return blocks def validate_mermaid_block(block: MermaidBlock) -> bool: """Validate a mermaid block and populate errors/warnings""" content = block.content.strip() lines = content.split('\n') if not lines: block.errors.append("Empty mermaid block") return False first_line = lines[0].strip() # Detect diagram type for dtype in MERMAID_DIAGRAM_TYPES: if first_line.startswith(dtype): block.diagram_type = dtype break if not block.diagram_type: block.errors.append(f"Unknown diagram type: '{first_line[:30]}...'") block.errors.append(f"Valid types: {', '.join(MERMAID_DIAGRAM_TYPES[:8])}...") return False # Check for balanced brackets/braces brackets = {'[': ']', '{': '}', '(': ')'} stack = [] for i, char in enumerate(content): if char in brackets: stack.append((char, i)) elif char in brackets.values(): if not stack: block.errors.append(f"Unmatched closing bracket '{char}' at position {i}") else: open_char, _ = stack.pop() if brackets[open_char] != char: block.errors.append(f"Mismatched brackets: '{open_char}' and '{char}'") if stack: for open_char, pos in stack: block.warnings.append(f"Unclosed bracket '{open_char}' at position {pos}") # Check for common graph/flowchart issues if block.diagram_type in ['graph', 'flowchart']: # Check direction specifier if not re.match(r'^(graph|flowchart)\s+(TB|BT|LR|RL|TD)', first_line): block.warnings.append("Missing or invalid direction (TB/BT/LR/RL/TD)") # Check for arrow syntax arrow_count = content.count('-->') + content.count('---') + content.count('-.->') + content.count('==>') if arrow_count == 0 and len(lines) > 1: block.warnings.append("No arrows found - graph may be incomplete") # Check for sequenceDiagram issues if block.diagram_type == 'sequenceDiagram': if '->' not in content and '->>' not in content: block.warnings.append("No message arrows found in sequence diagram") # Indentation warning if block.indented: block.warnings.append("Indented code block - may not render in some markdown parsers") return len(block.errors) == 0 def validate_all_mermaid(nav_structure: List[Dict], sections_dir: Path) -> Dict[str, Any]: """Validate all mermaid blocks in all section files""" report = { 'total_blocks': 0, 'valid_blocks': 0, 'error_blocks': 0, 'warning_blocks': 0, 'blocks': [], 'by_file': {}, 'by_type': {} } for group in nav_structure: for item in group.get("items", []): section_file = item.get("file") if not section_file: continue filepath = sections_dir / section_file if not filepath.exists(): continue content = read_file(filepath) blocks = extract_mermaid_blocks(content, section_file) file_report = {'blocks': [], 'errors': 0, 'warnings': 0} for block in blocks: report['total_blocks'] += 1 is_valid = validate_mermaid_block(block) if is_valid: report['valid_blocks'] += 1 else: report['error_blocks'] += 1 file_report['errors'] += 1 if block.warnings: report['warning_blocks'] += 1 file_report['warnings'] += len(block.warnings) # Track by diagram type if block.diagram_type: if block.diagram_type not in report['by_type']: report['by_type'][block.diagram_type] = 0 report['by_type'][block.diagram_type] += 1 report['blocks'].append(block) file_report['blocks'].append(block) if blocks: report['by_file'][section_file] = file_report return report def print_mermaid_report(report: Dict[str, Any]) -> None: """Print mermaid validation report""" print("\n" + "=" * 60) print("MERMAID DIAGRAM VALIDATION REPORT") print("=" * 60) print(f"\nSummary:") print(f" Total blocks: {report['total_blocks']}") print(f" Valid: {report['valid_blocks']}") print(f" With errors: {report['error_blocks']}") print(f" With warnings: {report['warning_blocks']}") if report['by_type']: print(f"\nDiagram Types:") for dtype, count in sorted(report['by_type'].items()): print(f" {dtype}: {count}") # Print errors and warnings has_issues = False for block in report['blocks']: if block.errors or block.warnings: if not has_issues: print(f"\nIssues Found:") has_issues = True print(f"\n [{block.file}:{block.line_num}] {block.diagram_type or 'unknown'}") for error in block.errors: print(f" [ERROR] {error}") for warning in block.warnings: print(f" [WARN] {warning}") if not has_issues: print(f"\n No issues found!") print("=" * 60 + "\n") def convert_md_to_html(markdown_text: str) -> str: """Convert Markdown to HTML with syntax highlighting""" if not HAS_MARKDOWN: # Fallback: just escape HTML and wrap in pre escaped = markdown_text.replace('&', '&').replace('<', '<').replace('>', '>') return f'
{escaped}
' md = markdown.Markdown( extensions=[ FencedCodeExtension(), TableExtension(), TocExtension(toc_depth=3), CodeHiliteExtension( css_class='highlight', linenums=False, guess_lang=True, use_pygments=True ), ], output_format='html5' ) html = md.convert(markdown_text) md.reset() return html def embed_screenshot_base64(screenshot_id: str) -> str: """Embed screenshot as base64, using cache to avoid duplicates""" global _embedded_images filename = SCREENSHOT_MAPPING.get(screenshot_id) if not filename: return f'
📷 {screenshot_id}
' filepath = SCREENSHOTS_DIR / filename if not filepath.exists(): return f'
📷 {screenshot_id}
' # Check cache if filename not in _embedded_images: try: with open(filepath, 'rb') as f: image_data = base64.b64encode(f.read()).decode('utf-8') ext = filepath.suffix[1:].lower() _embedded_images[filename] = f"data:image/{ext};base64,{image_data}" except Exception as e: return f'
📷 {screenshot_id} (加载失败)
' return f'''
{screenshot_id}
{screenshot_id}
''' def process_markdown_screenshots(markdown_text: str) -> str: """Replace [[screenshot:xxx]] placeholders with embedded images""" pattern = r'\[\[screenshot:(.*?)\]\]' def replacer(match): screenshot_id = match.group(1) return embed_screenshot_base64(screenshot_id) return re.sub(pattern, replacer, markdown_text) def generate_sidebar_nav_html(nav_structure: List[Dict]) -> str: """Generate hierarchical sidebar navigation HTML""" html_parts = [] for group in nav_structure: if group["type"] == "group": expanded_class = "expanded" if group.get("expanded", False) else "" html_parts.append(f''' ''') return '\n'.join(html_parts) def generate_sections_html(nav_structure: List[Dict]) -> str: """Generate content sections HTML""" sections_html = [] for group in nav_structure: for item in group.get("items", []): section_id = item["id"] section_title = item["title"] section_file = item.get("file") if not section_file: continue filepath = SECTIONS_DIR / section_file if not filepath.exists(): print(f"Warning: Section file not found: {filepath}") continue # Read and convert markdown markdown_content = read_file(filepath) markdown_content = process_markdown_screenshots(markdown_content) html_content = convert_md_to_html(markdown_content) sections_html.append(f'''
{html_content}
''') return '\n'.join(sections_html) def generate_search_index(nav_structure: List[Dict]) -> str: """Generate search index JSON""" search_index = {} for group in nav_structure: for item in group.get("items", []): section_id = item["id"] section_file = item.get("file") if not section_file: continue filepath = SECTIONS_DIR / section_file if filepath.exists(): content = read_file(filepath) clean_content = re.sub(r'[#*`\[\]()]', '', content) clean_content = re.sub(r'\s+', ' ', clean_content)[:1500] search_index[section_id] = { "title": item["title"], "body": clean_content, "group": group["title"] } return json.dumps(search_index, ensure_ascii=False, indent=2) def generate_nav_structure_json(nav_structure: List[Dict]) -> str: """Generate navigation structure JSON for client-side""" return json.dumps(nav_structure, ensure_ascii=False, indent=2) def assemble_manual(validate_mermaid: bool = True): """Main assembly function Args: validate_mermaid: Whether to validate mermaid diagrams (default: True) """ global _embedded_images _embedded_images = {} full_title = f"{MANUAL_META['title']} {MANUAL_META['subtitle']}" print(f"Assembling Docsify-style manual: {full_title}") # Verify template exists if not TEMPLATE_FILE.exists(): print(f"Error: Template not found at {TEMPLATE_FILE}") print("Please update TEMPLATE_FILE path in this script.") return None, 0 if not CSS_BASE_FILE.exists(): print(f"Error: CSS not found at {CSS_BASE_FILE}") print("Please update CSS_BASE_FILE path in this script.") return None, 0 # Validate Mermaid diagrams mermaid_report = None if validate_mermaid: print("\nValidating Mermaid diagrams...") mermaid_report = validate_all_mermaid(NAV_STRUCTURE, SECTIONS_DIR) print_mermaid_report(mermaid_report) # Warn if there are errors (but continue) if mermaid_report['error_blocks'] > 0: print(f"[WARN] {mermaid_report['error_blocks']} mermaid block(s) have errors!") print(" These diagrams may not render correctly.") # Read template and CSS template_html = read_file(TEMPLATE_FILE) css_content = read_file(CSS_BASE_FILE) # Generate components sidebar_nav_html = generate_sidebar_nav_html(NAV_STRUCTURE) sections_html = generate_sections_html(NAV_STRUCTURE) search_index_json = generate_search_index(NAV_STRUCTURE) nav_structure_json = generate_nav_structure_json(NAV_STRUCTURE) # Replace placeholders output_html = template_html output_html = output_html.replace('{{SOFTWARE_NAME}}', full_title) output_html = output_html.replace('{{VERSION}}', MANUAL_META['version']) output_html = output_html.replace('{{TIMESTAMP}}', MANUAL_META['timestamp']) output_html = output_html.replace('{{LOGO_ICON}}', MANUAL_META['logo_icon']) output_html = output_html.replace('{{EMBEDDED_CSS}}', css_content) output_html = output_html.replace('{{SIDEBAR_NAV_HTML}}', sidebar_nav_html) output_html = output_html.replace('{{SECTIONS_HTML}}', sections_html) output_html = output_html.replace('{{SEARCH_INDEX_JSON}}', search_index_json) output_html = output_html.replace('{{NAV_STRUCTURE_JSON}}', nav_structure_json) # Write output file OUTPUT_FILE.write_text(output_html, encoding='utf-8') file_size = OUTPUT_FILE.stat().st_size file_size_mb = file_size / (1024 * 1024) section_count = sum(len(g.get("items", [])) for g in NAV_STRUCTURE) print("[OK] Docsify-style manual generated successfully!") print(f" Output: {OUTPUT_FILE}") print(f" Size: {file_size_mb:.2f} MB ({file_size:,} bytes)") print(f" Navigation Groups: {len(NAV_STRUCTURE)}") print(f" Sections: {section_count}") return str(OUTPUT_FILE), file_size if __name__ == "__main__": output_path, size = assemble_manual()