Skip to content

Instantly share code, notes, and snippets.

@MohamedElashri
Created September 25, 2025 16:02
Show Gist options
  • Select an option

  • Save MohamedElashri/b8bdf3611ac64582a6b8607a384c879d to your computer and use it in GitHub Desktop.

Select an option

Save MohamedElashri/b8bdf3611ac64582a6b8607a384c879d to your computer and use it in GitHub Desktop.

Revisions

  1. MohamedElashri created this gist Sep 25, 2025.
    446 changes: 446 additions & 0 deletions syntax_highlight.py
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,446 @@
    #!/usr/bin/env python3
    """
    Syntax Highlighting CSS Generator for Zola from Pygments Themes
    Description:
    Generate a combined light/dark mode syntax highlighting CSS file for Zola.
    This script takes one light theme and one dark theme from Pygments and produces
    a CSS file in the same format as the existing highlight.css file.
    Author: Mohamed Elashri
    Date: 2025-09-10
    Usage:
    python syntax_highlight.py --light github --dark monokai --output highlight.css
    Requirements:
    - Python 3.x
    - Pygments library (install via `pip install pygments`)
    License: MIT
    """

    import sys
    import argparse
    from pathlib import Path

    try:
    from pygments.styles import get_all_styles, get_style_by_name
    from pygments.formatters import HtmlFormatter
    except ImportError:
    print("Error: Pygments is required. Install it with: pip install pygments")
    sys.exit(1)


    class CombinedThemeGenerator:
    """Generate combined light/dark theme CSS for Zola."""

    # Mapping from Pygments token types to Zola semantic classes
    PYGMENTS_TO_ZOLA_MAPPING = {
    # Keywords
    'k': 'z-keyword', # Keyword
    'kc': 'z-keyword', # Keyword.Constant
    'kd': 'z-keyword', # Keyword.Declaration
    'kn': 'z-keyword', # Keyword.Namespace
    'kp': 'z-keyword', # Keyword.Pseudo
    'kr': 'z-keyword', # Keyword.Reserved
    'kt': 'z-storage.z-type', # Keyword.Type

    # Control keywords
    'k.control': 'z-keyword.z-control',

    # Names and Functions
    'n': 'z-entity.z-name', # Name
    'na': 'z-entity.z-name', # Name.Attribute
    'nb': 'z-entity.z-name', # Name.Builtin
    'bp': 'z-entity.z-name', # Name.Builtin.Pseudo
    'nc': 'z-entity.z-name', # Name.Class
    'no': 'z-entity.z-name', # Name.Constant
    'nd': 'z-entity.z-name', # Name.Decorator
    'ni': 'z-entity.z-name', # Name.Entity
    'ne': 'z-entity.z-name', # Name.Exception
    'nf': 'z-entity.z-name.z-function', # Name.Function
    'fm': 'z-entity.z-name.z-function', # Name.Function.Magic
    'nl': 'z-entity.z-name', # Name.Label
    'nn': 'z-entity.z-name', # Name.Namespace
    'nx': 'z-entity.z-name', # Name.Other
    'py': 'z-entity.z-name', # Name.Property
    'nt': 'z-entity.z-name', # Name.Tag
    'nv': 'z-variable', # Name.Variable
    'vc': 'z-variable', # Name.Variable.Class
    'vg': 'z-variable', # Name.Variable.Global
    'vi': 'z-variable', # Name.Variable.Instance
    'vm': 'z-variable', # Name.Variable.Magic

    # Literals
    'l': 'z-constant', # Literal
    'm': 'z-constant.z-numeric', # Literal.Number
    'mb': 'z-constant.z-numeric', # Literal.Number.Bin
    'mf': 'z-constant.z-numeric', # Literal.Number.Float
    'mh': 'z-constant.z-numeric', # Literal.Number.Hex
    'mi': 'z-constant.z-numeric', # Literal.Number.Integer
    'il': 'z-constant.z-numeric', # Literal.Number.Integer.Long
    'mo': 'z-constant.z-numeric', # Literal.Number.Oct

    # Strings
    's': 'z-string', # Literal.String
    'sa': 'z-string', # Literal.String.Affix
    'sb': 'z-string', # Literal.String.Backtick
    'sc': 'z-string', # Literal.String.Char
    'dl': 'z-string', # Literal.String.Delimiter
    'sd': 'z-string', # Literal.String.Doc
    's2': 'z-string', # Literal.String.Double
    'se': 'z-string', # Literal.String.Escape
    'sh': 'z-string', # Literal.String.Heredoc
    'si': 'z-string', # Literal.String.Interpol
    'sx': 'z-string', # Literal.String.Other
    'sr': 'z-string', # Literal.String.Regex
    's1': 'z-string', # Literal.String.Single
    'ss': 'z-string', # Literal.String.Symbol

    # Comments
    'c': 'z-comment', # Comment
    'ch': 'z-comment', # Comment.Hashbang
    'cm': 'z-comment', # Comment.Multiline
    'c1': 'z-comment', # Comment.Single
    'cs': 'z-comment', # Comment.Special
    'cp': 'z-comment', # Comment.Preproc
    'cpf': 'z-comment', # Comment.PreprocFile

    # Operators
    'o': 'z-keyword.z-operator', # Operator
    'ow': 'z-keyword.z-operator', # Operator.Word

    # Punctuation
    'p': 'z-punctuation', # Punctuation

    # Other
    'g': 'z-meta', # Generic
    'gd': 'z-meta', # Generic.Deleted
    'ge': 'z-meta', # Generic.Emph
    'gr': 'z-meta', # Generic.Error
    'gh': 'z-meta', # Generic.Heading
    'gi': 'z-meta', # Generic.Inserted
    'go': 'z-meta', # Generic.Output
    'gp': 'z-meta', # Generic.Prompt
    'gs': 'z-meta', # Generic.Strong
    'gu': 'z-meta', # Generic.Subheading
    'gt': 'z-meta', # Generic.Traceback
    'w': 'z-meta', # Text.Whitespace
    }

    def __init__(self):
    self.available_styles = list(get_all_styles())

    def _get_theme_colors(self, theme_name: str):
    """Extract background and foreground colors from a Pygments theme."""
    try:
    style_class = get_style_by_name(theme_name)
    background = getattr(style_class, 'background_color', '#ffffff')

    # Find a reasonable foreground color from the theme
    foreground = '#000000' # default
    for token_type, style in style_class.styles.items():
    if style and '#' in style:
    # Extract color from style string
    parts = style.split()
    for part in parts:
    if part.startswith('#') and len(part) == 7:
    foreground = part
    break
    if foreground != '#000000':
    break

    return background, foreground
    except:
    return '#ffffff', '#000000'

    def _adjust_color_brightness(self, hex_color: str, factor: float) -> str:
    """Adjust brightness of a hex color for border generation."""
    if not hex_color.startswith('#'):
    return hex_color

    try:
    # Remove # and convert to RGB
    hex_color = hex_color[1:]
    r = int(hex_color[0:2], 16)
    g = int(hex_color[2:4], 16)
    b = int(hex_color[4:6], 16)

    # Adjust brightness
    if factor > 0: # Lighten
    r = min(255, max(0, int(r + (255 - r) * factor)))
    g = min(255, max(0, int(g + (255 - g) * factor)))
    b = min(255, max(0, int(b + (255 - b) * factor)))
    else: # Darken
    factor = abs(factor)
    r = max(0, int(r * (1 - factor)))
    g = max(0, int(g * (1 - factor)))
    b = max(0, int(b * (1 - factor)))

    return f"#{r:02x}{g:02x}{b:02x}"
    except:
    return hex_color

    def _parse_pygments_css(self, css_content: str):
    """Parse Pygments CSS and extract style rules."""
    rules = {}
    lines = css_content.split('\n')

    current_selector = None
    current_styles = {}
    in_rule = False

    for line in lines:
    line = line.strip()

    if line.startswith('.highlight .') and '{' in line:
    # Handle single-line rule
    parts = line.split('{', 1)
    if len(parts) == 2:
    selector = parts[0].strip()
    styles_part = parts[1].replace('}', '').strip()

    # Extract class name from selector
    class_name = selector.replace('.highlight .', '').strip()

    # Parse styles
    styles = {}
    if styles_part:
    for style in styles_part.split(';'):
    if ':' in style:
    prop, value = style.split(':', 1)
    styles[prop.strip()] = value.strip()

    if styles:
    rules[class_name] = styles

    elif line.startswith('.highlight .') and line.endswith('{'):
    # Start of multi-line rule
    current_selector = line.replace('{', '').strip()
    current_styles = {}
    in_rule = True

    elif in_rule and line == '}':
    # End of multi-line rule
    if current_selector and current_styles:
    class_name = current_selector.replace('.highlight .', '').strip()
    rules[class_name] = current_styles
    current_selector = None
    current_styles = {}
    in_rule = False

    elif in_rule and ':' in line:
    # Style property in multi-line rule
    prop, value = line.split(':', 1)
    prop = prop.strip()
    value = value.replace(';', '').strip()
    if prop and value:
    current_styles[prop] = value

    return rules

    def _generate_zola_classes(self, pygments_rules: dict, is_dark_mode: bool = False) -> str:
    """Convert Pygments rules to Zola classes."""
    zola_styles = {}

    # Group Pygments classes by their Zola equivalents
    for pygments_class, styles in pygments_rules.items():
    if pygments_class in self.PYGMENTS_TO_ZOLA_MAPPING:
    zola_class = self.PYGMENTS_TO_ZOLA_MAPPING[pygments_class]
    if zola_class in zola_styles:
    # Merge styles (later ones override earlier ones)
    zola_styles[zola_class].update(styles)
    else:
    zola_styles[zola_class] = styles.copy()

    # Generate CSS
    css_parts = []
    for zola_class, styles in sorted(zola_styles.items()):
    if not styles:
    continue

    css_props = []
    for prop, value in styles.items():
    css_props.append(f" {prop}: {value};")

    if css_props:
    css_parts.append(f" .{zola_class} {{\n" + "\n".join(css_props) + "\n }\n")

    return "\n".join(css_parts)

    def _generate_pygments_classes(self, pygments_rules: dict) -> str:
    """Generate Pygments classes CSS."""
    css_parts = []

    # Group similar classes together for cleaner output
    grouped_classes = {}
    for class_name, styles in pygments_rules.items():
    if not styles:
    continue

    style_key = tuple(sorted(styles.items()))
    if style_key not in grouped_classes:
    grouped_classes[style_key] = []
    grouped_classes[style_key].append(class_name)

    for style_items, class_names in grouped_classes.items():
    if not class_names:
    continue

    # Create selector
    selectors = [f".highlight .{class_name}" for class_name in class_names]
    selector = ", ".join(selectors)

    # Create CSS properties
    css_props = []
    for prop, value in style_items:
    css_props.append(f" {prop}: {value};")

    if css_props:
    css_parts.append(f" {selector} {{\n" + "\n".join(css_props) + "\n }")

    return "\n".join(css_parts)

    def generate_combined_css(self, light_theme: str, dark_theme: str) -> str:
    """Generate combined CSS with light and dark themes."""
    if light_theme not in self.available_styles:
    raise ValueError(f"Light theme '{light_theme}' not found. Available: {', '.join(sorted(self.available_styles))}")

    if dark_theme not in self.available_styles:
    raise ValueError(f"Dark theme '{dark_theme}' not found. Available: {', '.join(sorted(self.available_styles))}")

    # Generate Pygments CSS for both themes
    light_formatter = HtmlFormatter(style=light_theme, cssclass='highlight')
    dark_formatter = HtmlFormatter(style=dark_theme, cssclass='highlight')

    light_css = light_formatter.get_style_defs()
    dark_css = dark_formatter.get_style_defs()

    # Parse CSS rules
    light_rules = self._parse_pygments_css(light_css)
    dark_rules = self._parse_pygments_css(dark_css)

    # Get theme colors
    light_bg, light_fg = self._get_theme_colors(light_theme)
    dark_bg, dark_fg = self._get_theme_colors(dark_theme)

    # Generate the combined CSS
    css_content = f"""/* Syntax highlighting styles - {light_theme.title()} Light / {dark_theme.title()} Dark */
    .highlight, pre.z-code {{
    background: {light_bg};
    color: {light_fg};
    padding: 20px;
    border-radius: 6px;
    border: 1px solid {self._adjust_color_brightness(light_bg, 0.1)};
    margin: 2rem 0;
    overflow-x: auto;
    }}
    .highlight pre code {{
    margin-top: 0;
    margin-bottom: 0;
    }}
    /* Light mode Zola classes */
    {self._generate_zola_classes(light_rules, is_dark_mode=False)}
    /* Dark mode styles */
    @media (prefers-color-scheme: dark) {{
    .highlight, pre.z-code {{
    background-color: {dark_bg};
    color: {dark_fg};
    border: 1px solid {self._adjust_color_brightness(dark_bg, 0.1) if dark_bg.startswith('#') else 'rgba(255,255,255,0.07)'};
    }}
    {self._generate_zola_classes(dark_rules, is_dark_mode=True)}
    }}
    /* Pygments classes for compatibility */
    @media (prefers-color-scheme: dark) {{
    .highlight {{
    background-color: {dark_bg};
    color: {dark_fg};
    border: 1px solid rgba(255,255,255,0.07);
    }}
    {self._generate_pygments_classes(dark_rules)}
    }}"""

    return css_content


    def main():
    parser = argparse.ArgumentParser(
    description="Generate combined light/dark syntax highlighting CSS for Zola",
    formatter_class=argparse.RawDescriptionHelpFormatter,
    epilog="""
    Examples:
    %(prog)s --light github --dark monokai
    %(prog)s --light vs --dark gruvbox-dark --output my_highlight.css
    %(prog)s --list-themes
    """
    )

    parser.add_argument("--light", type=str, default="github",
    help="Light theme name (default: github)")
    parser.add_argument("--dark", type=str, default="monokai",
    help="Dark theme name (default: monokai)")
    parser.add_argument("--output", type=str, default="highlight.css",
    help="Output CSS file (default: highlight.css)")
    parser.add_argument("--list-themes", action="store_true",
    help="List all available Pygments themes")

    args = parser.parse_args()

    generator = CombinedThemeGenerator()

    if args.list_themes:
    print("Available Pygments themes:")
    themes = sorted(generator.available_styles)

    # Categorize themes for better display
    light_themes = []
    dark_themes = []
    other_themes = []

    for theme in themes:
    if any(word in theme.lower() for word in ['light', 'github', 'vs', 'default', 'friendly']):
    light_themes.append(theme)
    elif any(word in theme.lower() for word in ['dark', 'monokai', 'material', 'native', 'gruvbox-dark']):
    dark_themes.append(theme)
    else:
    other_themes.append(theme)

    if light_themes:
    print("\n🌞 LIGHT THEMES:")
    for theme in light_themes:
    print(f" {theme}")

    if dark_themes:
    print("\nπŸŒ™ DARK THEMES:")
    for theme in dark_themes:
    print(f" {theme}")

    if other_themes:
    print("\nπŸ“‹ OTHER THEMES:")
    for theme in other_themes:
    print(f" {theme}")

    return

    try:
    css_content = generator.generate_combined_css(args.light, args.dark)

    with open(args.output, 'w', encoding='utf-8') as f:
    f.write(css_content)

    print(f"βœ“ Generated combined CSS:")
    print(f" Light theme: {args.light}")
    print(f" Dark theme: {args.dark}")
    print(f" Output file: {args.output}")

    except Exception as e:
    print(f"Error: {e}")
    sys.exit(1)


    if __name__ == "__main__":
    main()