Created
September 25, 2025 16:02
-
-
Save MohamedElashri/b8bdf3611ac64582a6b8607a384c879d to your computer and use it in GitHub Desktop.
Syntax Highlighting CSS Generator for Zola from Pygments Themes
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment