feat(website): add --link-css and --embed-css for custom CSS (#288)

generate_website.py can layer custom CSS on top of the built-in style chosen with --style. --embed-css appends a local file to the page's <style> block so the page stays a single file, and --link-css adds a stylesheet link after it, so both override built-in rules of equal specificity. The flags can be combined (the linked stylesheet loads last), apply to --sample pages, and an unreadable --embed-css file stops generation with an error. The website docs gain a custom CSS guide and a reference for the generated page's structure, classes, and theme variables.
This commit is contained in:
Gerard Hickey authored and GitHub committed 2026-09-17 21:47:31 -07:00
1 parent c566fa97c8
commit 6686564a25
4 files changed
+743 -138

No files matched your search

+10
View File
@@ -6,6 +6,16 @@ semantic versioning.
## [Unreleased]
### Added
- `generate_website.py` accepts `--link-css URL` and `--embed-css FILE` to layer
custom CSS on top of the built-in style chosen with `--style` (#288). Embedded
CSS is appended to the page's `<style>` block, keeping the page a single file;
a linked stylesheet loads after it, so both override built-in rules of equal
specificity. Both flags also apply to `--sample` pages, and an unreadable
`--embed-css` file stops generation with an error. See
`docs/command-reference-website.md` for examples and the CSS class reference.
### Fixed
- A radio connection is no longer accepted when every channel read times out or
+417 -2
View File
@@ -27,6 +27,86 @@ python generate_website.py config.ini --style terminal
Available styles include: **default** (modern dark), **minimalist** (light, clean), **terminal** (green/amber on black), **glass** (glassmorphism), **neon** (cyberpunk), **brutalist** (bold, high contrast), **gradient** (colorful gradients), **pixel** (retro gaming). Run `--list-styles` for the full list and short descriptions.
## Add custom CSS
You can layer your own CSS on top of the built-in style chosen with `--style` (`default` if you don't pass one). The built-in CSS always loads first, so you only write the rules you want to change, and your rules win over built-in rules of equal specificity.
### Link to external CSS
Use `--link-css` to reference a stylesheet hosted on your server or a CDN:
```bash
python generate_website.py config.ini --link-css https://example.com/my-custom.css
python generate_website.py config.ini --style minimalist --link-css https://example.com/overrides.css
```
The `<link>` tag is placed after the built-in `<style>` block. A relative path such as `--link-css overrides.css` is resolved by the browser against `website/index.html`, so upload the stylesheet next to it.
### Embed CSS from a file
Use `--embed-css` to inline CSS from a local file into the generated HTML:
```bash
python generate_website.py config.ini --embed-css tweaks.css
python generate_website.py config.ini --style terminal --embed-css tweaks.css
```
The file's contents are appended to the built-in `<style>` block, so the page stays a single self-contained file. The path is relative to the directory you run the script from. If the file can't be read, the script exits with an error instead of generating the page.
This is useful for:
- Small tweaks to a built-in style (colors, fonts, spacing)
- Offline deployments, or hosts where you can only upload one file
### Using both
You can pass `--embed-css` and `--link-css` together. The page loads the built-in style, then the embedded CSS, then the linked stylesheet, so the linked stylesheet wins any conflict.
### Example: Custom color scheme
Create a file `my-colors.css`:
```css
:root {
--bg-primary: #1a1a2e;
--bg-card: #16213e;
--accent-blue: #0f3460;
--accent-cyan: #e94560;
--text-primary: #eee;
}
```
Then generate with:
```bash
python generate_website.py config.ini --style default --embed-css my-colors.css
```
This keeps all the layout and styling from the default theme but applies your custom color palette.
### Example workflow: Fine-tuning a built-in style
1. Generate with a built-in style you like:
```bash
python generate_website.py config.ini --style minimalist
```
2. Inspect the output and identify what you want to change (e.g., "I want blue accents instead of the default colors")
3. Create a small override file `tweaks.css` with just the changes:
```css
:root {
--accent-blue: #2563eb;
--accent-cyan: #0891b2;
}
```
4. Regenerate with your tweaks:
```bash
python generate_website.py config.ini --style minimalist --embed-css tweaks.css
```
This approach is much easier than writing CSS from scratch, since you only override specific properties while keeping all the responsive design, layouts, and other styling intact.
## Preview all styles
To generate a sample page for every style plus an index that links to them (useful to pick a theme):
@@ -35,7 +115,7 @@ To generate a sample page for every style plus an index that links to them (usef
python generate_website.py config.ini --sample
```
Output goes to `website/` with one HTML file per style and an `index.html` you can open locally.
Output goes to `website/` with one HTML file per style and an `index.html` you can open locally. `--link-css` and `--embed-css` apply to every sample page, so you can preview your overrides against each style.
## Custom title and intro
@@ -51,4 +131,339 @@ If omitted, the script uses the bot name and a default intro.
## Uploading
The script produces a self-contained HTML file (with embedded CSS). Upload `website/index.html` to any static host (e.g. GitHub Pages, Netlify, or your group's web server). No server-side processing is required.
The script produces a self-contained HTML file (with embedded CSS). Upload `website/index.html` to any static host (e.g. GitHub Pages, Netlify, or your group's web server). No server-side processing is required. If you used `--link-css` with a relative path, upload that stylesheet alongside `index.html`.
## CSS architecture and customization
The generated HTML includes embedded CSS that uses a **CSS custom properties (variables) system** for easy theming. This section documents the CSS architecture and how to create your own custom styles.
### CSS structure overview
The stylesheet is organized into several sections:
1. **CSS Custom Properties (`:root`)** - Theme variables for colors, fonts, spacing
2. **Reset & Base Styles** - Global resets and base element styles
3. **Layout Components** - Container, grid, sidebar navigation
4. **Header & Introduction** - Title area and intro text
5. **Command Cards** - Individual command display cards
6. **Channel Cards** - Channel listing cards
7. **Mobile Responsive** - Breakpoints and mobile menu
8. **Theme Overrides** - Style-specific customizations
### CSS custom properties (theme variables)
Most colors are defined as CSS custom properties in the `:root` selector, so you can retheme a page without touching the rest of the CSS. A few effects (background gradients, badge tints) and some style-specific overrides use literal colors instead.
#### Color variables
```css
:root {
/* Backgrounds */
--bg-primary: #0a0e14; /* Main page background */
--bg-secondary: #111820; /* Secondary background (usage boxes) */
--bg-card: #151c25; /* Card backgrounds */
--bg-card-hover: #1a232e; /* Card hover state */
/* Accent Colors */
--accent-blue: #00d4ff; /* Primary accent (command names, links) */
--accent-cyan: #00ffc8; /* Secondary accent (keywords, highlights) */
--accent-orange: #ff8a00; /* Parameter names */
--accent-purple: #a855f7; /* Only used by the neon style */
--accent-red: #ff4757; /* Not currently used */
--accent-yellow: #ffd700; /* Only used by the terminal style */
/* Text Colors */
--text-primary: #e8edf4; /* Main text color */
--text-secondary: #8892a4; /* Secondary text (descriptions) */
--text-muted: #6b7280; /* Muted text (labels, footers) */
/* Borders & Effects */
--border-subtle: rgba(255,255,255,0.06); /* Subtle borders */
--glow-blue: rgba(0, 212, 255, 0.15); /* Only used by the neon style */
--glow-cyan: rgba(0, 255, 200, 0.1); /* Not currently used */
}
```
#### Typography variables
Fonts are loaded via Google Fonts and applied through the CSS. The default theme uses:
- **Outfit** - Sans-serif for body text and headings
- **JetBrains Mono** - Monospace for code, keywords, and technical text
### HTML structure and CSS classes
#### Page layout
```html
<body>
<div class="atmosphere"></div> <!-- Background gradient effect -->
<div class="grid-overlay"></div> <!-- Subtle grid pattern -->
<div class="sidebar-overlay"></div> <!-- Mobile menu overlay -->
<button class="mobile-menu-toggle"> <!-- Mobile hamburger menu -->
<span class="hamburger"></span>
<span class="hamburger"></span>
<span class="hamburger"></span>
</button>
<div class="container">
<nav class="sidebar-nav"> <!-- Left sidebar navigation -->
<div class="nav-header">
<h3>Navigation</h3>
</div>
<ul class="nav-list">
<li class="nav-section-header">Commands</li>
<ul class="nav-sublist">
<li><a href="#..." class="nav-link nav-sublink">Category</a></li>
</ul>
</ul>
</nav>
<div class="main-content">
<header>
<div class="header-content">
<div class="header-title">
<h1>Bot Name</h1>
</div>
<div class="intro">Introduction text...</div>
</div>
</header>
<main>
<!-- Command categories -->
<div class="category-section" id="commands-category">
<h2 class="category-title">
<a href="#..." class="anchor-link">Category Name</a>
</h2>
<div class="commands-grid">
<!-- Command cards go here -->
</div>
</div>
<!-- Channels section -->
<div class="category-section" id="channels">
<h2 class="category-title">
<a href="#channels" class="anchor-link">Available Channels</a>
</h2>
<p class="channels-intro">...</p>
<div class="channel-category">
<h3 class="channel-category-title">...</h3>
<div class="channels-grid">
<!-- Channel cards go here -->
</div>
</div>
</div>
</main>
<footer>
<p>Generated command reference...</p>
</footer>
</div>
</div>
</body>
```
#### Command card structure
```html
<div class="command-card">
<div class="command-header">
<h3 class="command-name">commandname</h3>
<div class="command-keywords">
<span class="keyword-badge">alias1</span>
<span class="keyword-badge">alias2</span>
<span class="keyword-badge keyword-expand" data-hidden="...">+3 more</span>
</div>
</div>
<p class="command-description">Command description text</p>
<div class="command-usage">
<code>!commandname [args]</code>
</div>
<div class="command-params">
<div class="params-header">Parameters:</div>
<div class="param-item">
<span class="param-name">param1</span>
<span class="param-desc">Description</span>
</div>
</div>
<div class="command-subcommands">
<div class="subcommands-header">Sub-commands:</div>
<div class="subcommand-item">
<span class="subcommand-name">subcmd</span>
<span class="subcommand-desc">Description</span>
</div>
</div>
<div class="command-channels">Channel: #specific-channel</div>
</div>
```
#### Channel card structure
```html
<div class="channel-card">
<div class="channel-name">#channel-name</div>
<div class="channel-description">Channel description text</div>
</div>
```
### Key CSS classes reference
#### Layout classes
- `.container` - Main content wrapper with grid layout
- `.sidebar-nav` - Left sidebar navigation (sticky on desktop)
- `.main-content` - Right side main content area
- `.mobile-menu-toggle` - Hamburger menu button (mobile only)
- `.sidebar-overlay` - Dark overlay when mobile menu is open
#### Navigation classes
- `.nav-header` - Navigation section header
- `.nav-list` - Main navigation list
- `.nav-section-header` - Section divider in nav (e.g., "Commands", "Channels")
- `.nav-sublist` - Nested navigation list
- `.nav-link` - Navigation link item
- `.nav-sublink` - Nested/indented navigation link
#### Header classes
- `.header-content` - Header wrapper with background and border
- `.header-title` - Title container
- `.intro` - Introduction text paragraph
- `.channel-highlight` - Highlighted channel names in intro text
#### Content organization classes
- `.category-section` - Wrapper for each command category
- `.category-title` - Category heading (e.g., "Weather Commands")
- `.anchor-link` - Linkable heading anchor
- `.commands-grid` - CSS Grid container for command cards
- `.channels-grid` - CSS Grid container for channel cards
#### Command card classes
- `.command-card` - Individual command card container
- `.command-header` - Command name and keywords section
- `.command-name` - Command name heading
- `.command-keywords` - Container for keyword badges
- `.keyword-badge` - Individual keyword/alias badge
- `.keyword-expand` - "+X more" expandable badge
- `.keyword-hidden` - Hidden keywords (shown on expand)
- `.command-description` - Command description paragraph
- `.command-usage` - Usage syntax box
- `.command-params` - Parameters section wrapper
- `.params-header` - "Parameters:" label
- `.param-item` - Individual parameter row
- `.param-name` - Parameter name
- `.param-desc` - Parameter description
- `.command-subcommands` - Sub-commands section wrapper
- `.subcommands-header` - "Sub-commands:" label
- `.subcommand-item` - Individual sub-command row
- `.subcommand-name` - Sub-command name
- `.subcommand-desc` - Sub-command description
- `.command-channels` - Channel restriction notice
#### Channel card classes
- `.channel-category` - Channel category wrapper
- `.channel-category-title` - Channel category heading
- `.channels-intro` - Introduction text for channels section
- `.channel-card` - Individual channel card
- `.channel-name` - Channel name (e.g., "#general")
- `.channel-description` - Channel description text
#### Utility classes
- `.atmosphere` - Background gradient effect layer
- `.grid-overlay` - Subtle grid pattern overlay
- `.hamburger` - Hamburger menu bar element
### Responsive breakpoints
The CSS includes two main responsive breakpoints:
- **1200px** - Switches to mobile layout, shows hamburger menu, sidebar becomes slide-out
- **768px** - Further mobile optimizations for small screens
Mobile behavior:
- Sidebar slides in from left when hamburger is clicked
- Overlay appears over main content
- Grid layouts adjust to single column
- Font sizes scale down slightly
### Tips for custom styles
1. **Start with variables** - Most visual changes can be achieved by only changing the CSS custom properties in `:root`
2. **Use the sample generator** - Run `--sample` with your `--embed-css` or `--link-css` flag to see your overrides on every built-in style
3. **Respect the structure** - The HTML structure and class names are semantic and should remain consistent
4. **Test mobile** - Always check your custom styles at mobile breakpoints (< 1200px, < 768px)
5. **Consider accessibility** - Ensure sufficient color contrast (WCAG AA: 4.5:1 for normal text, 3:1 for large text)
6. **Use monospace for code** - Keep `JetBrains Mono` or similar for `.keyword-badge`, `.command-usage code`, `.param-name`, etc.
7. **Preserve hover states** - Interactive elements should have clear hover/focus states for usability
8. **Match `!important` where needed** - The base CSS uses `!important` on many mobile-layout rules, and the brutalist, pixel, and minimalist styles use it heavily in their overrides. A custom rule only beats one of those if it's also `!important`.
9. **Some styles hard-code colors** - The gradient and brutalist styles set many colors directly instead of through the variables, so changing only the `:root` variables has less effect on them.
### Example style variations
#### Dark high-contrast
```css
:root {
--bg-primary: #000000;
--bg-card: #0a0a0a;
--accent-blue: #00ffff;
--accent-cyan: #00ff00;
--text-primary: #ffffff;
--border-subtle: rgba(255,255,255,0.2);
}
```
#### Light professional
```css
:root {
--bg-primary: #ffffff;
--bg-card: #f5f5f5;
--accent-blue: #0066cc;
--accent-cyan: #0088cc;
--text-primary: #333333;
--text-secondary: #666666;
--border-subtle: rgba(0,0,0,0.1);
}
```
#### Warm earth tones
```css
:root {
--bg-primary: #2c2416;
--bg-card: #3d2f1f;
--accent-blue: #d4a574;
--accent-cyan: #c9a961;
--accent-orange: #e6925b;
--text-primary: #f4e8d8;
--text-secondary: #b89968;
}
```
### JavaScript functionality
The generated HTML includes minimal JavaScript for:
1. **Mobile menu toggle** - Shows/hides sidebar navigation on mobile
2. **Keyword expansion** - Expands "+X more" keyword badges when clicked
Smooth scrolling to anchors comes from the CSS `scroll-behavior` property, not JavaScript.
These behaviors are built-in and don't require customization for basic styling changes.
+179 -136
View File
@@ -1284,7 +1284,7 @@ def format_monitor_channels(monitor_channels: list[str], html: bool = False) ->
return ", ".join(formatted[:-1]) + f", or {formatted[-1]}"
def generate_html(bot_name: str, title: str, introduction: str, commands: list[tuple[str, Any]], monitor_channels: list[str] = None, channels_data: dict[str, dict[str, str]] = None, style: str = 'default') -> str:
def generate_html(bot_name: str, title: str, introduction: str, commands: list[tuple[str, Any]], monitor_channels: list[str] = None, channels_data: dict[str, dict[str, str]] = None, style: str = 'default', link_css: Optional[str] = None, custom_css: Optional[str] = None) -> str:
"""Generate the HTML content"""
if monitor_channels is None:
@@ -1519,6 +1519,17 @@ def generate_html(bot_name: str, title: str, introduction: str, commands: list[t
channels_html += '</div>\n'
# Custom CSS cascades after the built-in style: embedded CSS is appended to
# the <style> block and a linked stylesheet follows it, so both can override
# built-in rules of equal specificity.
css_content = generate_builtin_css(style)
if custom_css:
css_content += "\n\n /* Custom CSS overrides */\n" + custom_css
external_css_link = ""
if link_css:
external_css_link = f'\n <link rel="stylesheet" href="{escape_html(link_css)}">'
html_content = f"""<!DOCTYPE html>
<html lang="en">
<head>
@@ -1529,7 +1540,144 @@ def generate_html(bot_name: str, title: str, introduction: str, commands: list[t
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="{STYLES[style]['fonts_url']}" rel="stylesheet">
<style>
:root {{
{css_content}
</style>{external_css_link}
</head>"""
# Continue with body
html_content += f"""
<body>
<div class="atmosphere"></div>
<div class="grid-overlay"></div>
<div class="sidebar-overlay"></div>
<button class="mobile-menu-toggle" aria-label="Toggle navigation menu">
<span class="hamburger"></span>
<span class="hamburger"></span>
<span class="hamburger"></span>
</button>
<div class="container">
{nav_html}
<div class="main-content">
<header>
<div class="header-content">
<div class="header-title">
<h1>{escape_html(bot_name)}</h1>
</div>
<div class="intro">
{escape_html(introduction).replace(chr(10), '<br>')}{channels_suffix}
</div>
</div>
</header>
<main>
{commands_html}
{channels_html}
</main>
<footer>
<p>Generated command reference for {escape_html(bot_name)}</p>
</footer>
</div>
</div>
<script>
// Handle mobile menu toggle
(function() {{
'use strict';
const menuToggle = document.querySelector('.mobile-menu-toggle');
const sidebar = document.querySelector('.sidebar-nav');
const overlay = document.querySelector('.sidebar-overlay');
if (menuToggle && sidebar) {{
function toggleMenu() {{
const isOpen = sidebar.classList.contains('open');
if (isOpen) {{
menuToggle.classList.remove('active');
sidebar.classList.remove('open');
if (overlay) {{
overlay.classList.remove('visible');
}}
}} else {{
menuToggle.classList.add('active');
sidebar.classList.add('open');
if (overlay) {{
overlay.classList.add('visible');
}}
}}
}}
function closeMenu() {{
menuToggle.classList.remove('active');
sidebar.classList.remove('open');
if (overlay) {{
overlay.classList.remove('visible');
}}
}}
menuToggle.addEventListener('click', function(e) {{
e.stopPropagation();
e.preventDefault();
toggleMenu();
return false;
}});
// Handle overlay clicks - overlay now only covers area after sidebar
if (overlay) {{
overlay.addEventListener('click', function(e) {{
closeMenu();
}});
}}
// Handle nav link clicks - don't prevent default, let browser handle navigation
const navLinks = sidebar.querySelectorAll('.nav-link');
navLinks.forEach(link => {{
link.addEventListener('click', function(e) {{
if (window.innerWidth <= 1200) {{
// Close menu after a delay to allow navigation
setTimeout(function() {{
closeMenu();
}}, 200);
}}
}});
}});
}}
// Handle keyword expansion
const expandButtons = document.querySelectorAll('.keyword-expand');
expandButtons.forEach(button => {{
button.addEventListener('click', function() {{
const hiddenAliases = this.getAttribute('data-hidden').split(',');
const commandKeywords = this.closest('.command-keywords');
// Hide the expand button
this.classList.add('expanded');
// Add hidden aliases as visible badges
hiddenAliases.forEach(alias => {{
const badge = document.createElement('span');
badge.className = 'keyword-badge keyword-hidden visible';
badge.textContent = alias.trim();
commandKeywords.appendChild(badge);
}});
}});
}});
}})();
</script>
</body>
</html>"""
return html_content
def generate_builtin_css(style: str) -> str:
"""Generate the built-in CSS for a given style"""
return f""" :root {{
{STYLES[style]['css_vars']}
}}
@@ -2368,136 +2516,7 @@ def generate_html(bot_name: str, title: str, introduction: str, commands: list[t
}}
/* Style-specific CSS overrides */
{STYLES[style].get('css_overrides', '')}
</style>
</head>
<body>
<div class="atmosphere"></div>
<div class="grid-overlay"></div>
<div class="sidebar-overlay"></div>
<button class="mobile-menu-toggle" aria-label="Toggle navigation menu">
<span class="hamburger"></span>
<span class="hamburger"></span>
<span class="hamburger"></span>
</button>
<div class="container">
{nav_html}
<div class="main-content">
<header>
<div class="header-content">
<div class="header-title">
<h1>{escape_html(bot_name)}</h1>
</div>
<div class="intro">
{escape_html(introduction).replace(chr(10), '<br>')}{channels_suffix}
</div>
</div>
</header>
<main>
{commands_html}
{channels_html}
</main>
<footer>
<p>Generated command reference for {escape_html(bot_name)}</p>
</footer>
</div>
</div>
<script>
// Handle mobile menu toggle
(function() {{
'use strict';
const menuToggle = document.querySelector('.mobile-menu-toggle');
const sidebar = document.querySelector('.sidebar-nav');
const overlay = document.querySelector('.sidebar-overlay');
if (menuToggle && sidebar) {{
function toggleMenu() {{
const isOpen = sidebar.classList.contains('open');
if (isOpen) {{
menuToggle.classList.remove('active');
sidebar.classList.remove('open');
if (overlay) {{
overlay.classList.remove('visible');
}}
}} else {{
menuToggle.classList.add('active');
sidebar.classList.add('open');
if (overlay) {{
overlay.classList.add('visible');
}}
}}
}}
function closeMenu() {{
menuToggle.classList.remove('active');
sidebar.classList.remove('open');
if (overlay) {{
overlay.classList.remove('visible');
}}
}}
menuToggle.addEventListener('click', function(e) {{
e.stopPropagation();
e.preventDefault();
toggleMenu();
return false;
}});
// Handle overlay clicks - overlay now only covers area after sidebar
if (overlay) {{
overlay.addEventListener('click', function(e) {{
closeMenu();
}});
}}
// Handle nav link clicks - don't prevent default, let browser handle navigation
const navLinks = sidebar.querySelectorAll('.nav-link');
navLinks.forEach(link => {{
link.addEventListener('click', function(e) {{
if (window.innerWidth <= 1200) {{
// Close menu after a delay to allow navigation
setTimeout(function() {{
closeMenu();
}}, 200);
}}
}});
}});
}}
// Handle keyword expansion
const expandButtons = document.querySelectorAll('.keyword-expand');
expandButtons.forEach(button => {{
button.addEventListener('click', function() {{
const hiddenAliases = this.getAttribute('data-hidden').split(',');
const commandKeywords = this.closest('.command-keywords');
// Hide the expand button
this.classList.add('expanded');
// Add hidden aliases as visible badges
hiddenAliases.forEach(alias => {{
const badge = document.createElement('span');
badge.className = 'keyword-badge keyword-hidden visible';
badge.textContent = alias.trim();
commandKeywords.appendChild(badge);
}});
}});
}});
}})();
</script>
</body>
</html>"""
return html_content
{STYLES[style].get('css_overrides', '')}"""
def list_styles():
@@ -2513,7 +2532,7 @@ def list_styles():
print(f" {' ' * max_len} {style_info['description']}\n")
def generate_samples(config_file):
def generate_samples(config_file, link_css: Optional[str] = None, custom_css: Optional[str] = None):
"""Generate sample HTML files for all styles with an index page."""
logger = logging.getLogger(__name__)
@@ -2582,7 +2601,9 @@ def generate_samples(config_file):
commands=sorted_commands,
monitor_channels=monitor_channels,
channels_data=channels_data,
style=style_name
style=style_name,
link_css=link_css,
custom_css=custom_css
)
output_path = os.path.join(output_dir, f'{style_name}.html')
@@ -2730,6 +2751,18 @@ def main():
help='Generate sample pages for all styles with an index.html'
)
parser.add_argument(
'--link-css',
metavar='URL',
help='Link a stylesheet after the built-in style so its rules override it'
)
parser.add_argument(
'--embed-css',
metavar='FILE',
help='Inline CSS from a local file after the built-in style so its rules override it'
)
args = parser.parse_args()
# Handle --list-styles flag
@@ -2737,9 +2770,19 @@ def main():
list_styles()
sys.exit(0)
# Read --embed-css up front so a bad path fails before plugins load
custom_css = None
if args.embed_css:
try:
with open(args.embed_css, encoding='utf-8') as f:
custom_css = f.read()
except (OSError, UnicodeDecodeError) as e:
logger.error(f"Could not read --embed-css file {args.embed_css}: {e}")
sys.exit(1)
# Handle --sample flag
if args.sample:
generate_samples(args.config)
generate_samples(args.config, link_css=args.link_css, custom_css=custom_css)
sys.exit(0)
config_file = args.config
@@ -2822,7 +2865,7 @@ def main():
# Generate HTML
logger.info("Generating HTML...")
html_content = generate_html(bot_name, title, introduction, sorted_commands, monitor_channels, channels_data, style)
html_content = generate_html(bot_name, title, introduction, sorted_commands, monitor_channels, channels_data, style, args.link_css, custom_css)
# Create website directory
website_dir = os.path.join(bot_root, "website")
+137
View File
@@ -0,0 +1,137 @@
"""Tests for the --link-css and --embed-css options in generate_website.py"""
import sys
import pytest
import generate_website
from generate_website import STYLES, generate_builtin_css, generate_html
def _render(**kwargs) -> str:
return generate_html(
bot_name="TestBot",
title="Test Site",
introduction="Test intro",
commands=[],
monitor_channels=[],
channels_data={},
**kwargs,
)
def test_generate_builtin_css_includes_css_variables():
css = generate_builtin_css('default')
assert ':root' in css
assert '--bg-primary' in css
assert '--accent-blue' in css
assert 'body {' in css
def test_generate_builtin_css_includes_style_overrides():
css = generate_builtin_css('minimalist')
assert STYLES['minimalist']['css_overrides'].strip() in css
def test_default_output_has_no_custom_css():
html = _render(style='default')
assert 'Custom CSS overrides' not in html
stylesheets = [line for line in html.splitlines() if 'rel="stylesheet"' in line]
assert len(stylesheets) == 1
assert 'fonts.googleapis.com/css2' in stylesheets[0]
def test_backwards_compatible_call_without_css_params():
html = generate_html("TestBot", "Test Site", "Test intro", [], [], {}, 'default')
assert '<style>' in html
assert '--bg-primary' in html
def test_link_css_follows_builtin_style():
"""The linked stylesheet must load after the <style> block to win the cascade."""
html = _render(style='minimalist', link_css='https://cdn.example.com/overrides.css')
link = '<link rel="stylesheet" href="https://cdn.example.com/overrides.css">'
assert link in html
assert html.index('</style>') < html.index(link) < html.index('</head>')
assert 'fonts.googleapis.com' in html
assert '--bg-primary' in html
def test_link_css_is_escaped():
html = _render(style='default', link_css='style.css?v=1&theme=dark"><script>alert(1)</script>')
assert 'href="style.css?v=1&amp;theme=dark&quot;&gt;&lt;script&gt;' in html
assert '<script>alert(1)' not in html
def test_embed_css_follows_builtin_css():
html = _render(style='default', custom_css=':root {\n --bg-primary: #123456;\n}\n')
style_block = html[html.index('<style>'):html.index('</style>')]
builtin_pos = style_block.index('--bg-primary: #0a0e14')
custom_pos = style_block.index('--bg-primary: #123456')
assert builtin_pos < custom_pos
assert 'Custom CSS overrides' in style_block
def test_embed_and_link_css_together():
html = _render(
style='default',
link_css='overrides.css',
custom_css='.command-card { border-radius: 0; }',
)
embedded_pos = html.index('.command-card { border-radius: 0; }')
link_pos = html.index('<link rel="stylesheet" href="overrides.css">')
assert embedded_pos < html.index('</style>') < link_pos
@pytest.mark.parametrize('style', list(STYLES))
def test_every_style_accepts_custom_css(style):
html = _render(style=style, link_css='overrides.css', custom_css='.x { color: red; }')
assert STYLES[style]['fonts_url'] in html
assert '.x { color: red; }' in html
assert 'href="overrides.css"' in html
assert 'mobile-menu-toggle' in html
def test_main_exits_when_embed_css_is_unreadable(monkeypatch, tmp_path):
def fail_read_config(*args, **kwargs):
raise AssertionError("config should not be read when --embed-css is unreadable")
monkeypatch.setattr(generate_website, 'read_config', fail_read_config)
monkeypatch.setattr(sys, 'argv', ['generate_website.py', '--embed-css', str(tmp_path / 'missing.css')])
with pytest.raises(SystemExit) as exc:
generate_website.main()
assert exc.value.code == 1
def test_main_passes_custom_css_to_samples(monkeypatch, tmp_path):
css_file = tmp_path / 'tweaks.css'
css_file.write_text(':root { --accent-blue: #2563eb; }', encoding='utf-8')
captured = {}
def fake_generate_samples(config_file, link_css=None, custom_css=None):
captured.update(config_file=config_file, link_css=link_css, custom_css=custom_css)
monkeypatch.setattr(generate_website, 'generate_samples', fake_generate_samples)
monkeypatch.setattr(sys, 'argv', [
'generate_website.py', 'my.ini', '--sample',
'--link-css', 'overrides.css', '--embed-css', str(css_file),
])
with pytest.raises(SystemExit) as exc:
generate_website.main()
assert exc.value.code == 0
assert captured == {
'config_file': 'my.ini',
'link_css': 'overrides.css',
'custom_css': ':root { --accent-blue: #2563eb; }',
}