Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support dark mode #44

Merged
merged 13 commits into from
Sep 12, 2024
25 changes: 19 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,16 +37,19 @@ plugins:
num_workers: 8
puml_keyword: puml
verify_ssl: true
auto_dark: false
```

Where

| Parmeter | Type | Descripton |
|----------|----------------------|----------------------------------------------------------------|
| `puml_url` | `str`. Required | URL to the plantuml service |
| `num_workers` | `int`. Default `8` | Max amount of concurrent workers that request plantuml service |
| `puml_keyword` | `str`. Default `puml` | The keyword for PlantUML code fence, i.e. \```puml \``` |
| `verify_ssl` | `bool`. Default `True` | Designates whether `requests` should verify SSL or not |
| Parameter | Type | Description |
|----------------|------------------------|-----------------------------------------------------------------------------|
| `puml_url` | `str`. Required | URL to the PlantUML service |
| `num_workers` | `int`. Default `8` | Max amount of concurrent workers that request the PlantUML service |
| `puml_keyword` | `str`. Default `puml` | The keyword for PlantUML code fence, i.e. \```puml \``` |
| `verify_ssl` | `bool`. Default `True` | Designates whether `requests` should verify SSL or not |
| `auto_dark` | `bool`. Default `False`| Designates whether the plugin should also generate dark mode images |


Now, you can put your puml diagrams into your `.md` documentation. For example,

Expand All @@ -63,6 +66,16 @@ Bob -> Alice : hello
At the build step `mkdocs` sends requests to `puml_url` and substitutes your
diagram with the `svg` images from the responses.

### Dark Mode Support

The module supports dark mode, this can be enabled with the `auto_dark` option.

When this option is set the module will generate a second copy of the diagram in dark mode using the `/dsvg` server option.

In order to dynamically switch the image choice the module include a javascript file [dark.js](mkdocs_puml/static/dark.js).

> **Tip:** Try it with the `skinparam backgroundColor transparent` directive in your puml to see if it looks better :)

### Run PlantUML service with Docker

It is possible to run [plantuml/plantuml-server](https://hub.docker.com/r/plantuml/plantuml-server)
Expand Down
70 changes: 60 additions & 10 deletions mkdocs_puml/plugin.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import typing
import re
import uuid
import os
import shutil

from mkdocs.config.config_options import Type, Config
from mkdocs.plugins import BasePlugin
Expand Down Expand Up @@ -32,6 +34,7 @@ class PlantUMLPlugin(BasePlugin):
diagrams (dict): Dictionary containing the diagrams (puml and later svg) and their keys
puml_keyword (str): keyword used to find PlantUML blocks within Markdown files
verify_ssl (bool): Designates whether the ``requests`` should verify SSL certiticate
auto_dark (bool): Designates whether the plugin should automatically generate dark mode images.
"""
div_class_name = "puml"
pre_class_name = "diagram-uuid"
Expand All @@ -40,7 +43,8 @@ class PlantUMLPlugin(BasePlugin):
('puml_url', Type(str, required=True)),
('num_workers', Type(int, default=8)),
('puml_keyword', Type(str, default='puml')),
('verify_ssl', Type(bool, default=True))
('verify_ssl', Type(bool, default=True)),
('auto_dark', Type(bool, default=True))
OnceUponALoop marked this conversation as resolved.
Show resolved Hide resolved
)

def __init__(self):
Expand All @@ -67,10 +71,11 @@ def on_config(self, config: Config) -> Config:
self.puml = PlantUML(
self.config['puml_url'],
num_workers=self.config['num_workers'],
verify_ssl=self.config['verify_ssl']
verify_ssl=self.config['verify_ssl'],
)
self.puml_keyword = self.config['puml_keyword']
self.regex = re.compile(rf"```{self.puml_keyword}(.+?)```", flags=re.DOTALL)
self.regex = re.compile(rf"```{self.puml_keyword}(\n.+?)```", flags=re.DOTALL)
OnceUponALoop marked this conversation as resolved.
Show resolved Hide resolved
self.auto_dark = self.config['auto_dark']
return config

def on_page_markdown(self, markdown: str, *args, **kwargs) -> str:
Expand Down Expand Up @@ -111,10 +116,15 @@ def on_env(self, env, *args, **kwargs):
Returns:
Jinja environment
"""
resp = self.puml.translate(self.diagrams.values())

for key, svg in zip(self.diagrams.keys(), resp):
self.diagrams[key] = svg
if self.auto_dark:
light_svgs = self.puml.translate(self.diagrams.values())
dark_svgs = self.puml.translate(self.diagrams.values(), dark_mode=True)
for key, (light_svg, dark_svg) in zip(self.diagrams.keys(), zip(light_svgs, dark_svgs)):
OnceUponALoop marked this conversation as resolved.
Show resolved Hide resolved
self.diagrams[key] = (light_svg, dark_svg)
else:
svgs = self.puml.translate(self.diagrams.values())
for key, svg in zip(self.diagrams.keys(), svgs):
self.diagrams[key] = (svg, None)
return env

def on_post_page(self, output: str, page, *args, **kwargs) -> str:
Expand All @@ -139,14 +149,54 @@ def on_post_page(self, output: str, page, *args, **kwargs) -> str:
# TODO: Remove the support of older versions in future releases
if hasattr(page, 'html') and page.html is not None:
page.html = self._replace(v, page.html)

# Inject custom JavaScript only if PUML diagrams are present
script_tag = '<script src="assets/javascripts/puml/dark.js"></script>'
if script_tag not in output:
output = output.replace('</body>', f'{script_tag}</body>')

return output

def _replace(self, key: str, content: str) -> str:
"""Replace a UUID key with a real diagram in a
content
"""
return content.replace(
f'<pre class="{self.pre_class_name}">{key}</pre>',
f'<div class="{self.div_class_name}">{self.diagrams[key]}</div>'
light_svg, dark_svg = self.diagrams[key]
if dark_svg:
replacement = (
f'<div class="{self.div_class_name}" data-puml-theme="light">{light_svg}</div>'
f'<div class="{self.div_class_name}" data-puml-theme="dark" style="display:none;">{dark_svg}</div>'
)
else:
replacement = f'<div class="{self.div_class_name}">{light_svg}</div>'
return content.replace(
f'<pre class="{self.pre_class_name}">{key}</pre>',
replacement
)

def on_post_build(self, config):
"""
Event triggered after the build process is complete.

This method is responsible for copying static files from the plugin's
`static` directory to the specified `assets/javascripts/puml` directory
in the site output. This ensures that the necessary JavaScript files
are available in the final site.

Args:
config (dict): The MkDocs configuration object.

"""
# Path to the static directory in the plugin
static_dir = os.path.join(os.path.dirname(__file__), 'static')
# Destination directory in the site output
dest_dir = os.path.join(config['site_dir'], 'assets/javascripts/puml')

if not os.path.exists(dest_dir):
os.makedirs(dest_dir)

# Copy static files
for file_name in os.listdir(static_dir):
full_file_name = os.path.join(static_dir, file_name)
if os.path.isfile(full_file_name):
shutil.copy(full_file_name, dest_dir)
15 changes: 10 additions & 5 deletions mkdocs_puml/puml.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ def __init__(self, base_url: str, num_workers: int = 5, verify_ssl: bool = True)
self.num_workers = num_workers
self.verify_ssl = verify_ssl

def translate(self, diagrams: typing.Iterable[str]) -> typing.List[str]:
def translate(self, diagrams: typing.Iterable[str], dark_mode: bool = False) -> typing.List[str]:
OnceUponALoop marked this conversation as resolved.
Show resolved Hide resolved
"""Translate string diagram into HTML div
block containing the received SVG image.

Expand All @@ -46,13 +46,15 @@ def translate(self, diagrams: typing.Iterable[str]) -> typing.List[str]:

Args:
diagrams (list): string representation of PUML diagram
dark_mode (bool): Flag to indicate if dark mode variant is requested

Returns:
SVG image of built diagram
SVG image of built diagram
"""
encoded = [self.preprocess(v) for v in diagrams]

with ThreadPoolExecutor(max_workers=self.num_workers) as executor:
svg_images = executor.map(self.request, encoded)
svg_images = executor.map(lambda ed: self.request(ed, dark_mode), encoded)

return [self.postprocess(v) for v in svg_images]

Expand Down Expand Up @@ -89,19 +91,22 @@ def postprocess(self, content: str) -> str:

return svg.toxml()

def request(self, encoded_diagram: str) -> str:
def request(self, encoded_diagram: str, dark_mode: bool = False) -> str:
"""Request plantuml service with the encoded diagram;
return SVG content

Args:
encoded_diagram (str): Encoded string representation of the diagram
dark_mode (bool): Flag to indicate if dark mode variant is requested
Returns:
SVG representation of the diagram
"""
format_path = "dsvg" if dark_mode else self._format
resp = requests.get(
urljoin(self.base_url, f"{self._format}/{encoded_diagram}"),
urljoin(self.base_url, f"{format_path}/{encoded_diagram}"),
verify=self.verify_ssl
)
return resp.content.decode('utf-8', errors='ignore')
OnceUponALoop marked this conversation as resolved.
Show resolved Hide resolved

# Use 'ignore' to strip non-utf chars
return resp.content.decode('utf-8', errors='ignore')
Expand Down
142 changes: 142 additions & 0 deletions mkdocs_puml/static/dark.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
/**
* mkdocs-puml plugin - Auto Dark Mode Script
* ==========================================
*
* This script is designed to work with the mkdocs-puml plugin and the
* mkdocs-material theme to automatically switch between light and dark themes.
*
* Dependencies:
* -------------
* - The script requires the mkdocs-puml plugin to be installed and configured
* in the MkDocs project.
* - The script is intended to be used with the mkdocs-material theme.
*
* Description:
* ------------
* The script listens for changes in the system preference for dark mode and
* updates the theme accordingly. It also listens for changes in the MkDocs
* theme preference and updates the theme based on the user's selection.
* The script toggles the visibility of PlantUML divs based on the theme to ensure
* that the correct image is displayed.
*
*/

// Set this flag to true for debugging output, false to suppress logs
const DEBUG = true;

/**
* Helper function for controlled logging.
* @param {string} message - The message to log.
*/
function log(message) {
if (DEBUG) {
console.log(message);
}
}

/**
* Retrieves the current theme state based on system preferences.
* @returns {string} - The current theme state ('dark' or 'light').
*/
function getThemeState() {
const systemPreference = window.matchMedia("(prefers-color-scheme: dark)")
.matches
? "dark"
: "light";
log(`🌐 System Preference: ${systemPreference}`);
return systemPreference;
}

/**
* Retrieves the current theme state based on the data attribute on the body.
* @returns {string} - The current theme state ('dark', 'light', or 'auto').
*/
function getMkdocsThemeState() {
const colorScheme = document.body.getAttribute("data-md-color-media");

let themeState;
switch (colorScheme) {
case "(prefers-color-scheme: dark)":
themeState = "dark";
break;
case "(prefers-color-scheme: light)":
themeState = "light";
break;
case "(prefers-color-scheme)":
themeState = "auto";
break;
default:
themeState = getThemeState();
break;
}

log(`📋 Mkdocs Preference: ${themeState}`);
return themeState;
}

/**
* Updates the theme based on the provided mode.
* @param {string} mode - The theme mode ('dark' or 'light').
*/
function updateTheme(mode) {
log(`🔄 Updating theme`);
togglePumlVisibility(mode);
}

/**
* Toggles the visibility of PUML divs based on the theme.
* @param {string} mode - The theme mode ('dark' or 'light').
*/
function togglePumlVisibility(mode) {
const lightDivs = document.querySelectorAll(`[data-puml-theme="light"]`);
const darkDivs = document.querySelectorAll(`[data-puml-theme="dark"]`);

lightDivs.forEach((div) => {
div.style.display = mode === "light" ? "block" : "none";
});

darkDivs.forEach((div) => {
div.style.display = mode === "dark" ? "block" : "none";
});

log(`🌓 PUML visibility toggled to ${mode} mode.`);
}

// Main script logic
document.addEventListener("DOMContentLoaded", () => {
// Handle initial theme setup
const mkdocsTheme = getMkdocsThemeState();
const systemTheme = getThemeState();
let currentTheme;

if (mkdocsTheme === "auto") {
currentTheme = systemTheme;
} else {
currentTheme = mkdocsTheme;
}

updateTheme(currentTheme);

// Listen for system preference changes if the theme is set to 'auto'
const darkModeMediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
darkModeMediaQuery.addEventListener("change", (e) => {
const newTheme = e.matches ? "dark" : "light";
log(`⚡ System preference changed to: ${newTheme} mode`);
// if not in auto mode, do nothing
if (getMkdocsThemeState() === "auto") {
updateTheme(newTheme);
}
});

// Set up event listeners for the theme toggle inputs
document.querySelectorAll('input[name="__palette"]').forEach((input) => {
input.addEventListener("change", () => {
log(`⚡ Mkdocs preference changed`);
let newTheme = getMkdocsThemeState();
if (newTheme === "auto") {
newTheme = getThemeState();
}
updateTheme(newTheme);
});
});
});
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,7 @@ Home = "https://github.com/MikhailKravets/mkdocs_puml"

[project.entry-points."mkdocs.plugins"]
plantuml = "mkdocs_puml.plugin:PlantUMLPlugin"

[tool.flit.sdist]
# Specify the files to include in the source distribution
include = ["mkdocs_puml/static/custom.js"]