diff --git a/.gitmodules b/.gitmodules index 10263e5..d4e9cba 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,6 @@ [submodule "assets/reveal.js"] - path = assets/reveal.js + path = src/mkslides/assets/reveal.js url = https://github.com/hakimel/reveal.js.git [submodule "assets/highlight.js"] - path = assets/highlight.js + path = src/mkslides/assets/highlight.js url = https://github.com/highlightjs/cdn-release.git diff --git a/README.md b/README.md index 92d530d..ecd3d9a 100644 --- a/README.md +++ b/README.md @@ -63,37 +63,106 @@ mkslides serve test.md Just create a `mkslides.yml`. All options are optional, you only have to add what you want to change to `mkslides.yml`. -Here's an example: +Here's an example showcasing all possible options in the config file: ```yml +# Configuration for the generated index page index: + # Title of the generated index page: string title: example-title + # Favicon of the generated index page: file path or public url to favicon + # file favicon: ./example-index-favicon.ico + # Theme of the generated index page: file path or public url to CSS file theme: example-index-theme.css +# Configuration for the slides slides: + # Favicon of the slides: file path or public url to favicon file favicon: ./example-slides-favicon.ico + # Theme of the slides: file path to CSS file, public url to CSS file, or one + # of the reveal.js themes such as `black`, `white`, `league`, `solarized`, + # `dracula`, ... (see https://revealjs.com/themes/) theme: example-slides-theme.css + # Theme for syntax highlighting of code fragments on the slides: file path + # to CSS file, public url to CSS file, or one of the highlight.js built-in + # themes such as `monokai`, `obsidian`, `tokyo-night-dark`, `vs`, ... + # (see https://highlightjs.org/examples) highlight_theme: example-slides-highlight-theme.css + # Separator to determine end current/begin new slide: regexp + # (see https://revealjs.com/markdown/#external-markdown) separator: ^\s*---\s*$ + # Separator to determine end current/begin new vertical slide: regexp + # (see https://revealjs.com/markdown/#external-markdown) separator_vertical: ^\s*-v-\s*$ + # Separator to determine notes of the slide: regexp + # (see https://revealjs.com/markdown/#external-markdown) separator_notes: "^Notes?:" + # Charset of the slides: string + # (see https://revealjs.com/markdown/#external-markdown) separator_charset: utf-8 +# Options to be passed to reveal.js: options in yaml format, they will be +# translated to JSON automatically (see https://revealjs.com/config/) revealjs: height: 1080 width: 1920 transition: fade +# Plugins or additional CSS/JavaScript files for the slides. These are given as +# a list. plugins: + # Name of the plugin (optional, see plugin README): plugin id string + # (see https://revealjs.com/creating-plugins/#registering-a-plugin) - name: RevealMermaid + # List of JavaScript files of the plugin: file path or public url to + # JavaScript file per entry extra_javascript: - https://cdn.jsdelivr.net/npm/reveal.js-mermaid-plugin/plugin/mermaid/mermaid.min.js - extra_javascript: - https://cdn.jsdelivr.net/npm/reveal-plantuml/dist/reveal-plantuml.min.js ``` -- `favicon`and `theme`, can also be configured as an URL, e.g. `https://example.org/theme.css`. -- `theme` can also be configured as a [Reveal.js built-in theme](https://revealjs.com/themes/), e.g. `black`, `white`, `league`, `solarized`, `dracula`, ... . -- `highlight_theme` can also be configured as a [highlight.js built-in theme](https://highlightjs.org/examples), e.g. `monokai`, `obsidian`, `tokyo-night-dark`, `vs`, ... . -- `revealjs` can contain all [Reveal.js options](https://revealjs.com/config/). +Default config: + +```yml +index: + title: Index +slides: + theme: black + highlight_theme: monokai +revealjs: + history: true + slideNumber: c/t +``` + +It is also possible to override `slides`, `revealjs`, and `plugins` options on a per markdown file base using it's frontmatter: + +```md +--- +title: frontmatter title +slides: + theme: solarized + highlight_theme: vs + separator: +revealjs: + height: 1080 + width: 1920 + transition: zoom +--- + +# Slides with frontmatter + + + +## Lorem ipsum + +Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. + + +``` + +Notes: + +- `title` here is a frontmatter-only available option to set the title of this slideshow in the generated index page. This option is not available in `mkslides.yml`. +- The precedence is frontmatter > `mkslides.yml` > defaults. ## Contributing diff --git a/assets/mkslides.default.yml b/assets/mkslides.default.yml deleted file mode 100644 index 269a3ec..0000000 --- a/assets/mkslides.default.yml +++ /dev/null @@ -1,8 +0,0 @@ -index: - title: Index -slides: - theme: black - highlight_theme: monokai -revealjs: - history: true # Necessary for back/forward buttons and livereload - slideNumber: "c/t" diff --git a/poetry.lock b/poetry.lock index a08340e..7cefc34 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,5 +1,15 @@ # This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +[[package]] +name = "antlr4-python3-runtime" +version = "4.9.3" +description = "ANTLR 4.9.3 runtime for Python 3.7" +optional = false +python-versions = "*" +files = [ + {file = "antlr4-python3-runtime-4.9.3.tar.gz", hash = "sha256:f224469b4168294902bb1efa80a8bf7855f24c99aef99cbefc1bcd3cce77881b"}, +] + [[package]] name = "attrs" version = "24.2.0" @@ -390,6 +400,21 @@ files = [ fast = ["fastnumbers (>=2.0.0)"] icu = ["PyICU (>=1.0.0)"] +[[package]] +name = "omegaconf" +version = "2.3.0" +description = "A flexible configuration library" +optional = false +python-versions = ">=3.6" +files = [ + {file = "omegaconf-2.3.0-py3-none-any.whl", hash = "sha256:7b4df175cdb08ba400f45cae3bdcae7ba8365db4d165fc65fd04b050ab63b46b"}, + {file = "omegaconf-2.3.0.tar.gz", hash = "sha256:d5d4b6d29955cc50ad50c46dc269bcd92c6e00f5f90d23ab5fee7bfca4ba4cc7"}, +] + +[package.dependencies] +antlr4-python3-runtime = "==4.9.*" +PyYAML = ">=5.1.0" + [[package]] name = "packaging" version = "24.1" @@ -805,4 +830,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "c6099f81833afedbbacda8cbd50fe1cd0ebd20cf91af1223b882099c1130328a" +content-hash = "fbe2475dd2486cac3ca5b40462ed3e012d08bf5ce5ad7db1732e1bc519079d52" diff --git a/pyproject.toml b/pyproject.toml index fa726df..bc8dfb8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,10 +7,7 @@ homepage = "https://martenbe.github.io/mkslides" repository = "https://github.com/MartenBE/mkslides" license = "MIT" readme = "README.md" -packages = [ - { include = "mkslides", from = "src" }, -] -include = [{ path = "assets", format = ["sdist", "wheel"] }] +packages = [{ include = "mkslides", from = "src" }] [tool.poetry.dependencies] python = "^3.12" @@ -27,6 +24,7 @@ markdown = "^3.7" beautifulsoup4 = "^4.12.3" types-markdown = "^3.7.0.20240822" types-beautifulsoup4 = "^4.12.0.20241020" +omegaconf = "^2.3.0" [tool.poetry.group.dev.dependencies] bumpver = "^2023.1129" @@ -61,6 +59,9 @@ push = true "src/mkslides/__init__.py" = ['^__version__ = "{version}"'] "pyproject.toml" = ['^version = "{version}"', '^current_version = "{version}"'] +[tool.pytest.ini_options] +pythonpath = ["assets"] + [tool.ruff.lint] ignore = [ "ANN401", # Dynamically typed expressions (typing.Any) are disallowed @@ -73,12 +74,15 @@ ignore = [ "D203", # one-blank-line-before-class "D212", # multi-line-summary-first-line "E501", # Line too long + "FA100", # Add `from __future__ import annotations` to simplify `typing.Optional` "FA102", # Missing `from __future__ import annotations`, but uses PEP 585 collection "FBT001", # Boolean-typed positional argument in function definition "G004", # Logging statement uses f-string" - "PLR0913", # Too many arguments in function definition + "PLR0913", # Too many arguments in function definition "RET504", # Unnecessary assignment to `...` before `return` statement "S101", # Use of `assert` detected + "S603", # `subprocess` call: check for execution of untrusted input + "S607", # Starting a process with a partial executable path "TD002", # Missing author in TODO; try: `# TODO(): ...` or `# TODO @: ...` "TD003", # Missing issue link on the line following this TODO diff --git a/src/mkslides/__main__.py b/src/mkslides/__main__.py index ba0910a..6513881 100644 --- a/src/mkslides/__main__.py +++ b/src/mkslides/__main__.py @@ -1,43 +1,42 @@ import logging -import shutil import tempfile from pathlib import Path -from urllib.parse import urlparse import click -import livereload # type: ignore[import-untyped] -from livereload.handlers import LiveReloadHandler # type: ignore[import-untyped] +from omegaconf import OmegaConf from rich.logging import RichHandler -from .config import Config +from mkslides.serve import serve +from mkslides.utils import parse_ip_port + +from .build import build +from .config import get_config from .constants import ( DEFAULT_OUTPUT_DIR, - EXPECTED_CONFIG_LOCATION, HIGHLIGHTJS_THEMES_VERSION, REVEALJS_VERSION, VERSION, ) -from .markupgenerator import MarkupGenerator logger = logging.getLogger() -logger.setLevel("DEBUG") +logger.setLevel("INFO") logger.addHandler(RichHandler(show_path=False)) -LiveReloadHandler.DEFAULT_RELOAD_TIME = ( - 0 # https://github.com/lepture/python-livereload/pull/244 -) - ################################################################################ -context_settings = {"help_option_names": ["-h", "--help"], "max_content_width": 120} +context_settings = { + "help_option_names": ["-h", "--help"], + "max_content_width": 120, +} files_argument_data = { "metavar": "FILENAME|PATH", + "type": click.Path(exists=True, resolve_path=True, path_type=Path), } config_file_argument_data = { "metavar": "FILENAME", - "default": EXPECTED_CONFIG_LOCATION, + "type": click.Path(exists=True, resolve_path=True, path_type=Path), "help": "Provide a specific MkSlides-Reveal config file.", } @@ -50,67 +49,54 @@ message=f"mkslides, version {VERSION}\nreveal.js, version {REVEALJS_VERSION}\nhighlight.js themes, version {HIGHLIGHTJS_THEMES_VERSION}" "", ) -def cli() -> None: +@click.option( + "-v", + "--verbose", + "verbose", + help="Enable verbose output", + is_flag=True, +) +def cli(verbose: bool) -> None: """MkSlides - Slides with Markdown using the power of Reveal.js.""" + if verbose: + logger.setLevel("DEBUG") + logger.debug("Verbose output enabled") -def read_config(config_location: str) -> Config: - config_path = Path(config_location).resolve() - config = Config() - - if config_path.exists(): - logger.info(f'Config file found at "{config_path.absolute()}"') - config.merge_config_from_file(config_path) - - return config +# Build Command ################################################################ -def parse_ip_port( - ip_port_str: str, -) -> tuple[str, int]: - urlparse_result = urlparse(f"//{ip_port_str}") - ip = urlparse_result.hostname - port = urlparse_result.port - - assert ip, f"Invalid IP address: {ip_port_str}" - assert port, f"Invalid port: {ip_port_str}" - - return ip, port - - -def generate(config_file: str, input_path: Path, output_directory: Path) -> None: - config = read_config(config_file) - markup_generator = MarkupGenerator(config, output_directory) - markup_generator.create_output_directory() - markup_generator.process_markdown(input_path) - - -@cli.command() +@cli.command(name="build") @click.argument("files", **files_argument_data) # type: ignore[arg-type] @click.option("-f", "--config-file", **config_file_argument_data) # type: ignore[arg-type] @click.option( "-d", "--site-dir", + type=click.Path(path_type=Path), help="The directory to output the result of the slides build.", metavar="PATH", default=DEFAULT_OUTPUT_DIR, ) -def build(files: str, config_file: str, site_dir: str) -> None: +def build_command(files: Path, config_file: Path | None, site_dir: str) -> None: """ Build the MkDocs documentation. FILENAME|PATH is the path to the Markdown file, or the directory containing Markdown files. """ - logger.info("Command: build") + logger.debug("Command: build") + + config = get_config(config_file) + output_path = Path(site_dir).resolve(strict=False) + + build(config, files, output_path) - input_path = Path(files).resolve(strict=True) - output_directory = Path(site_dir).resolve(strict=False) - generate(config_file, input_path, output_directory) +# Serve Command ################################################################ -@cli.command() +@cli.command(name="serve") @click.argument("files", **files_argument_data) # type: ignore[arg-type] +@click.option("-f", "--config-file", **config_file_argument_data) # type: ignore[arg-type] @click.option( "-a", "--dev-addr", @@ -125,110 +111,39 @@ def build(files: str, config_file: str, site_dir: str) -> None: help="Open the website in a Web browser after the initial build finishes.", is_flag=True, ) -@click.option( - "--watch-index-theme", - help="Include the index theme in list of files to watch for live reloading.", - is_flag=True, -) -@click.option( - "--watch-index-template", - help="Include the index template in list of files to watch for live reloading.", - is_flag=True, -) -@click.option( - "--watch-slides-theme", - help="Include the slides theme in list of files to watch for live reloading.", - is_flag=True, -) -@click.option( - "--watch-slides-template", - help="Include the slides template in list of files to watch for live reloading.", - is_flag=True, -) -@click.option("-f", "--config-file", **config_file_argument_data) # type: ignore[arg-type] -def serve( # noqa: C901 - files: str, +def serve_command( + files: Path, + config_file: Path | None, dev_addr: str, open_in_browser: bool, - watch_index_theme: bool, - watch_index_template: bool, - watch_slides_theme: bool, - watch_slides_template: bool, - config_file: str, ) -> None: """ Run the builtin development server. FILENAME|PATH is the path to the Markdown file, or the directory containing Markdown files. """ - logger.info("Command: serve") - - input_path = Path(files).resolve(strict=True) - site_dir = tempfile.mkdtemp(prefix="mkslides_") - output_directory = Path(site_dir).resolve(strict=False) - - generate(config_file, input_path, output_directory) - config = read_config(config_file) - - # Livereload - - def reload() -> None: - logger.info("Reloading ...") - generate(config_file, input_path, output_directory) - - try: - server = livereload.Server() - - # https://github.com/lepture/python-livereload/issues/232 - server._setup_logging = ( # noqa: SLF001 - lambda: None - ) - - watched_paths = [ - files, - config_file, - ] - - if watch_index_theme: - index_theme = config.get_index_theme() - if index_theme is not None: - watched_paths.append(index_theme) - - if watch_index_template: - index_template = config.get_index_template() - if index_template is not None: - watched_paths.append(index_template) - - if watch_slides_theme: - slides_theme = config.get_slides_theme() - if slides_theme is not None: - watched_paths.append(slides_theme) - - if watch_slides_template: - slides_template = config.get_slides_template() - if slides_template is not None: - watched_paths.append(slides_template) - - for path in watched_paths: - if path: - resolved_path = Path(path).resolve(strict=True).absolute() - logger.info(f'Watching: "{resolved_path}"') - server.watch(filepath=resolved_path.as_posix(), func=reload, delay=1) - - ip, port = parse_ip_port(dev_addr) - - server.serve( - host=ip, - port=port, - root=output_directory, - open_url_delay=0 if open_in_browser else None, - ) - - finally: - if output_directory.exists(): - shutil.rmtree(output_directory) - logger.info(f'Removed "{output_directory}"') + logger.debug("Command: serve") + + config = get_config(config_file) + output_path = Path(tempfile.mkdtemp(prefix="mkslides_")).resolve(strict=False) + dev_ip, dev_port = parse_ip_port(dev_addr) + serve_config = OmegaConf.structured( + { + "dev_ip": dev_ip, + "dev_port": dev_port, + "open_in_browser": open_in_browser, + }, + ) + + serve( + config, + files, + output_path, + serve_config, + ) +################################################################################ + if __name__ == "__main__": cli() diff --git a/assets/highlight.js b/src/mkslides/assets/highlight.js similarity index 100% rename from assets/highlight.js rename to src/mkslides/assets/highlight.js diff --git a/assets/reveal.js b/src/mkslides/assets/reveal.js similarity index 100% rename from assets/reveal.js rename to src/mkslides/assets/reveal.js diff --git a/assets/templates/index.html.jinja b/src/mkslides/assets/templates/index.html.jinja similarity index 100% rename from assets/templates/index.html.jinja rename to src/mkslides/assets/templates/index.html.jinja diff --git a/assets/templates/slideshow.html.jinja b/src/mkslides/assets/templates/slideshow.html.jinja similarity index 100% rename from assets/templates/slideshow.html.jinja rename to src/mkslides/assets/templates/slideshow.html.jinja diff --git a/src/mkslides/build.py b/src/mkslides/build.py new file mode 100644 index 0000000..6c0d124 --- /dev/null +++ b/src/mkslides/build.py @@ -0,0 +1,14 @@ +import logging +from pathlib import Path + +from omegaconf import DictConfig + +from mkslides.markupgenerator import MarkupGenerator + +logger = logging.getLogger(__name__) + + +def build(config: DictConfig, input_path: Path, output_path: Path) -> None: + markup_generator = MarkupGenerator(config, output_path) + markup_generator.create_or_clear_output_directory() + markup_generator.process_markdown(input_path) diff --git a/src/mkslides/config.py b/src/mkslides/config.py index b04c28f..e45f3e2 100644 --- a/src/mkslides/config.py +++ b/src/mkslides/config.py @@ -1,122 +1,132 @@ import logging +from dataclasses import dataclass, field from pathlib import Path -from typing import Any +from typing import Any, Dict, Optional -import yaml +from omegaconf import MISSING, DictConfig, OmegaConf -from .constants import DEFAULT_CONFIG_RESOURCE +from mkslides.constants import ( + DEFAULT_CONFIG_LOCATION, + HIGHLIGHTJS_THEMES_LIST, + REVEALJS_THEMES_LIST, +) +from mkslides.urltype import URLType +from mkslides.utils import get_url_type logger = logging.getLogger(__name__) +FRONTMATTER_ALLOWED_KEYS = ["slides", "revealjs", "plugins"] + +@dataclass +class Index: + favicon: Optional[str] = None + template: Optional[str] = None + theme: Optional[str] = None + title: str = "Index" + + +@dataclass +class Slides: + charset: Optional[str] = None + favicon: Optional[str] = None + highlight_theme: str = "monokai" + separator_notes: Optional[str] = None + separator_vertical: Optional[str] = None + separator: Optional[str] = None + template: Optional[str] = None + theme: str = "black" + + +@dataclass +class Plugin: + name: Optional[str] = None + extra_javascript: list[str] = MISSING + + +# For internal use only +@dataclass +class Internal: + config_path: Optional[Path] = MISSING + + +@dataclass class Config: - def __init__(self) -> None: - with DEFAULT_CONFIG_RESOURCE.open() as f: - self.__config = yaml.safe_load(f) - - logger.info(f'Default config loaded from "{DEFAULT_CONFIG_RESOURCE}"') - logger.info(f"Default config: {self.__config}") - - def get_index_title(self) -> str | None: - value = self.__get("index", "title") - assert isinstance(value, str) or value is None - return value - - def get_index_favicon(self) -> str | None: - value = self.__get("index", "favicon") - assert isinstance(value, str) or value is None - return value - - def get_index_theme(self) -> str | None: - value = self.__get("index", "theme") - assert isinstance(value, str) or value is None - return value - - def get_index_template(self) -> str | None: - value = self.__get("index", "template") - assert isinstance(value, str) or value is None - return value - - def get_slides_favicon(self) -> str | None: - value = self.__get("slides", "favicon") - assert isinstance(value, str) or value is None - return value - - def get_slides_theme(self) -> str | None: - value = self.__get("slides", "theme") - assert isinstance(value, str) or value is None - return value - - def get_slides_highlight_theme(self) -> str | None: - value = self.__get("slides", "highlight_theme") - assert isinstance(value, str) or value is None - return value - - def get_slides_template(self) -> str | None: - value = self.__get("slides", "template") - assert isinstance(value, str) or value is None - return value - - def get_slides_separator(self) -> str | None: - value = self.__get("slides", "separator") - assert isinstance(value, str) or value is None - return value - - def get_slides_separator_vertical(self) -> str | None: - value = self.__get("slides", "separator_vertical") - assert isinstance(value, str) or value is None - return value - - def get_slides_separator_notes(self) -> str | None: - value = self.__get("slides", "separator_notes") - assert isinstance(value, str) or value is None - return value - - def get_slides_charset(self) -> str | None: - value = self.__get("slides", "charset") - assert isinstance(value, str) or value is None - return value - - def get_revealjs_options(self) -> dict | None: - value = self.__get("revealjs") - assert isinstance(value, dict) or value is None - return value - - def get_plugins(self) -> list | None: - value = self.__get("plugins") - assert isinstance(value, list) or value is None - return value - - def merge_config_from_file(self, config_path: Path) -> None: - with config_path.open(encoding="utf-8-sig") as f: - new_config = yaml.safe_load(f) - - self.__config = self.__recursive_merge(self.__config, new_config) - - logger.info(f'Config merged from "{config_path}"') - logger.info(f"Config: {self.__config}") - - def merge_config_from_dict(self, new_config: dict) -> None: - self.__config = self.__recursive_merge(self.__config, new_config) - - logger.info("Config merged from dict") - logger.info(f"Config: {self.__config}") - - def __get(self, *keys: str) -> str | dict | list | None: - current_value = self.__config - for key in keys: - if isinstance(current_value, dict) and key in current_value: - current_value = current_value[key] - else: - return None - return current_value - - def __recursive_merge(self, current: Any, new: dict) -> dict: - if new: - for key, value in new.items(): - if isinstance(value, dict): - current[key] = self.__recursive_merge(current.get(key, {}), value) - else: - current[key] = value - - return current + index: Index = field(default_factory=Index) + slides: Slides = field(default_factory=Slides) + revealjs: Dict[str, Any] = field( + default_factory=lambda: { + "history": True, # Necessary for back/forward buttons and livereload + "slideNumber": "c/t", + }, + ) + plugins: list[Plugin] = field(default_factory=list) + internal: Internal = field(default_factory=Internal) + + +def validate(config: DictConfig) -> None: + if config.index.favicon and get_url_type(config.index.favicon) == URLType.RELATIVE: + Path(config.index.favicon).resolve(strict=True) + + if ( + config.index.template + and get_url_type(config.index.template) == URLType.RELATIVE + ): + Path(config.index.template).resolve(strict=True) + + if ( + config.index.theme + and get_url_type(config.index.theme) == URLType.RELATIVE + and config.index.theme not in REVEALJS_THEMES_LIST + ): + Path(config.index.theme).resolve(strict=True) + + if ( + config.slides.favicon + and get_url_type(config.slides.favicon) == URLType.RELATIVE + ): + Path(config.slides.favicon).resolve(strict=True) + + if ( + config.slides.highlight_theme + and get_url_type(config.slides.highlight_theme) == URLType.RELATIVE + and config.slides.highlight_theme not in HIGHLIGHTJS_THEMES_LIST + ): + Path(config.slides.highlight_theme).resolve(strict=True) + + if ( + config.slides.template + and get_url_type(config.slides.template) == URLType.RELATIVE + ): + Path(config.slides.template).resolve(strict=True) + + if ( + config.slides.theme + and get_url_type(config.slides.theme) == URLType.RELATIVE + and config.slides.theme not in REVEALJS_THEMES_LIST + ): + Path(config.slides.theme).resolve(strict=True) + + +def get_config(config_file: Path | None = None) -> DictConfig: + config = OmegaConf.structured(Config) + + if not config_file and DEFAULT_CONFIG_LOCATION.exists(): + config_file = DEFAULT_CONFIG_LOCATION.resolve(strict=True).absolute() + + if config_file: + try: + loaded_config = OmegaConf.load(config_file) + config = OmegaConf.merge(config, loaded_config) + config.internal.config_path = config_file + logger.info(f'Loaded config from "{config_file}"') + except Exception: + logger.exception(f"Failed to load config from {config_file}") + raise + + assert OmegaConf.is_dict(config) + + logger.debug("Used config:") + logger.debug(OmegaConf.to_yaml(config, resolve=True)) + + return config diff --git a/src/mkslides/constants.py b/src/mkslides/constants.py index e72be9d..2289c61 100644 --- a/src/mkslides/constants.py +++ b/src/mkslides/constants.py @@ -1,9 +1,27 @@ import json import re from importlib import metadata, resources +from importlib.abc import Traversable +from pathlib import Path from jinja2 import Environment, FileSystemLoader, PackageLoader, select_autoescape +################################################################################ + + +def gather_themes(resource: Traversable) -> list[str]: + theme_names = [] + for theme in resource.iterdir(): + if theme.is_file(): + theme_path = Path(theme.name) + if theme_path.suffix == ".css": + theme_names.append(theme_path.stem) + + return theme_names + + +################################################################################ + HTML_BACKGROUND_IMAGE_REGEX = re.compile( r""" data-background-image= # data-background-image attribute @@ -14,18 +32,30 @@ re.VERBOSE, ) -EXPECTED_CONFIG_LOCATION = "mkslides.yml" +VERSION = metadata.version(__package__) +DEFAULT_CONFIG_LOCATION = Path("mkslides.yml") DEFAULT_OUTPUT_DIR = "site" -ASSETS_RESOURCE = resources.files("assets") -DEFAULT_CONFIG_RESOURCE = ASSETS_RESOURCE.joinpath("mkslides.default.yml") +ASSETS_RESOURCE = resources.files(__package__).joinpath("assets") + REVEALJS_RESOURCE = ASSETS_RESOURCE.joinpath("reveal.js") REVEALJS_THEMES_RESOURCE = REVEALJS_RESOURCE.joinpath("dist", "theme") +REVEALJS_THEMES_LIST = gather_themes(REVEALJS_THEMES_RESOURCE) +REVEALJS_VERSION = None +with REVEALJS_RESOURCE.joinpath("package.json").open(encoding="utf-8-sig") as f: + REVEALJS_VERSION = json.load(f)["version"] + HIGHLIGHTJS_RESOURCE = ASSETS_RESOURCE.joinpath("highlight.js") HIGHLIGHTJS_THEMES_RESOURCE = HIGHLIGHTJS_RESOURCE.joinpath("build", "styles") +HIGHLIGHTJS_THEMES_LIST = gather_themes(HIGHLIGHTJS_THEMES_RESOURCE) +HIGHLIGHTJS_THEMES_VERSION = None +with HIGHLIGHTJS_RESOURCE.joinpath("build", "package.json").open( + encoding="utf-8-sig", +) as f: + HIGHLIGHTJS_THEMES_VERSION = json.load(f)["version"] DEFAULT_JINJA2_ENVIRONMENT = Environment( - loader=PackageLoader("assets"), + loader=PackageLoader(__package__, "assets/templates"), autoescape=select_autoescape(), ) DEFAULT_INDEX_TEMPLATE = DEFAULT_JINJA2_ENVIRONMENT.get_template("index.html.jinja") @@ -33,15 +63,3 @@ "slideshow.html.jinja", ) LOCAL_JINJA2_ENVIRONMENT = Environment(loader=FileSystemLoader("."), autoescape=True) - -VERSION = metadata.version("mkslides") - -REVEALJS_VERSION = None -with REVEALJS_RESOURCE.joinpath("package.json").open(encoding="utf-8-sig") as f: - REVEALJS_VERSION = json.load(f)["version"] - -HIGHLIGHTJS_THEMES_VERSION = None -with HIGHLIGHTJS_RESOURCE.joinpath("build", "package.json").open( - encoding="utf-8-sig", -) as f: - HIGHLIGHTJS_THEMES_VERSION = json.load(f)["version"] diff --git a/src/mkslides/markupgenerator.py b/src/mkslides/markupgenerator.py index da318f4..832cd39 100644 --- a/src/mkslides/markupgenerator.py +++ b/src/mkslides/markupgenerator.py @@ -2,19 +2,22 @@ import logging import shutil import time +from copy import deepcopy from importlib import resources from importlib.resources.abc import Traversable from pathlib import Path from typing import Any -from urllib.parse import urlparse import frontmatter # type: ignore[import-untyped] import markdown from bs4 import BeautifulSoup, Comment from emoji import emojize from natsort import natsorted +from omegaconf import DictConfig, OmegaConf + +from mkslides.config import FRONTMATTER_ALLOWED_KEYS +from mkslides.utils import get_url_type -from .config import Config from .constants import ( DEFAULT_INDEX_TEMPLATE, DEFAULT_SLIDESHOW_TEMPLATE, @@ -32,18 +35,14 @@ class MarkupGenerator: def __init__( self, - config: Config, + global_config: DictConfig, output_directory_path: Path, ) -> None: - # Config - - self.config = config - - # Paths + self.global_config = global_config self.output_directory_path = output_directory_path.resolve(strict=False) logger.info( - f'Requested output directory: "{self.output_directory_path.absolute()}"', + f'Output directory: "{self.output_directory_path.absolute()}"', ) self.output_assets_path = self.output_directory_path / "assets" @@ -55,20 +54,20 @@ def clear_output_directory(self) -> None: item.unlink() elif item.is_dir(): shutil.rmtree(item) - logger.info("Output directory cleared") + logger.debug("Output directory cleared") - def create_output_directory(self) -> None: + def create_or_clear_output_directory(self) -> None: if self.output_directory_path.exists(): self.clear_output_directory() else: self.output_directory_path.mkdir(parents=True, exist_ok=True) - logger.info("Output directory created") + logger.debug("Output directory created") with resources.as_file(REVEALJS_RESOURCE) as revealjs_path: self.__copy(revealjs_path, self.output_revealjs_path) def process_markdown(self, input_path: Path) -> None: - logger.info("Processing markdown") + logger.debug("Processing markdown") start_time = time.perf_counter() if input_path.is_dir(): @@ -82,7 +81,7 @@ def process_markdown(self, input_path: Path) -> None: if output_markup_path.stem != "index": output_markup_path.rename(output_markup_path.with_stem("index")) - logger.info( + logger.debug( f'Renamed "{original_output_markup_path.absolute()}" to "{output_markup_path.absolute()}" as it was the only Markdown file', ) @@ -101,12 +100,24 @@ def __process_markdown_file( md_file = md_file.resolve(strict=True) md_root_path = md_root_path.resolve(strict=True) - logger.info(f'Processing markdown file at "{md_file.absolute()}"') + logger.debug(f'Processing markdown file at "{md_file.absolute()}"') # Retrieve the frontmatter metadata and the markdown content content = md_file.read_text(encoding="utf-8-sig") metadata, markdown_content = frontmatter.parse(content) + + slide_config = None + if metadata: + slide_config = deepcopy(self.global_config) + for key in FRONTMATTER_ALLOWED_KEYS: + if key in metadata: + OmegaConf.update(slide_config, key, metadata[key]) + logger.debug("Detected frontmatter, used config:") + logger.debug(OmegaConf.to_yaml(slide_config, resolve=True)) + else: + slide_config = self.global_config + markdown_content = emojize(markdown_content, language="alias") # Get the relative path of reveal.js @@ -121,12 +132,12 @@ def __process_markdown_file( walk_up=True, ) - revealjs_config = self.config.get_revealjs_options() + revealjs_config = slide_config.revealjs # Copy the theme CSS relative_theme_path = None - if theme := self.config.get_slides_theme(): + if theme := slide_config.slides.theme: relative_theme_path = self.__copy_theme( output_markup_path, theme, @@ -136,7 +147,7 @@ def __process_markdown_file( # Copy the highlight CSS relative_highlight_theme_path = None - if theme := self.config.get_slides_highlight_theme(): + if theme := slide_config.slides.highlight_theme: relative_highlight_theme_path = self.__copy_theme( output_markup_path, theme, @@ -146,18 +157,18 @@ def __process_markdown_file( # Copy the favicon relative_favicon_path = None - if favicon := self.config.get_slides_favicon(): + if favicon := slide_config.slides.favicon: relative_favicon_path = self.__copy_favicon(output_markup_path, favicon) # Retrieve the 3rd party plugins - plugins = self.config.get_plugins() + plugins = slide_config.plugins # Generate the markup from markdown # Refresh the templates here, so they have effect when live reloading slideshow_template = None - if template_config := self.config.get_slides_template(): + if template_config := slide_config.slides.template: slideshow_template = LOCAL_JINJA2_ENVIRONMENT.get_template(template_config) else: slideshow_template = DEFAULT_SLIDESHOW_TEMPLATE @@ -166,10 +177,10 @@ def __process_markdown_file( markdown_data_options = { key: value for key, value in { - "data-separator": self.config.get_slides_separator(), - "data-separator-vertical": self.config.get_slides_separator_vertical(), - "data-separator-notes": self.config.get_slides_separator_notes(), - "data-charset": self.config.get_slides_charset(), + "data-separator": slide_config.slides.separator, + "data-separator-vertical": slide_config.slides.separator_vertical, + "data-separator-notes": slide_config.slides.separator_notes, + "data-charset": slide_config.slides.charset, }.items() if value } @@ -190,55 +201,55 @@ def __process_markdown_file( self.__copy_local_files(md_file, md_root_path, markdown_content) - return metadata, output_markup_path + return metadata.get("title"), output_markup_path def __process_markdown_directory(self, md_root_path: Path) -> None: md_root_path = md_root_path.resolve(strict=True) - logger.info(f"Processing markdown directory at {md_root_path.absolute()}") + logger.debug(f'Processing markdown directory at "{md_root_path.absolute()}"') slideshows = [] for md_file in md_root_path.glob("**/*.md"): - (metadata, output_markup_path) = self.__process_markdown_file( + (title_for_index, output_markup_path) = self.__process_markdown_file( md_file, md_root_path, ) slideshows.append( { - "title": metadata.get("title", md_file.stem), + "title": title_for_index if title_for_index else md_file.stem, "location": output_markup_path.relative_to( self.output_directory_path, ), }, ) - slideshows = natsorted(slideshows, key=lambda x: x["location"]) + slideshows = natsorted(slideshows, key=lambda x: str(x["location"])) - logger.info("Generating index") + logger.debug("Generating index") index_path = self.output_directory_path / "index.html" # Copy the theme relative_theme_path = None - if theme := self.config.get_index_theme(): + if theme := self.global_config.index.theme: relative_theme_path = self.__copy_theme(index_path, theme) # Copy the favicon relative_favicon_path = None - if favicon := self.config.get_index_favicon(): + if favicon := self.global_config.index.favicon: relative_favicon_path = self.__copy_favicon(index_path, favicon) # Refresh the templates here, so they have effect when live reloading index_template = None - if template_config := self.config.get_index_template(): + if template_config := self.global_config.index.template: index_template = LOCAL_JINJA2_ENVIRONMENT.get_template(template_config) else: index_template = DEFAULT_INDEX_TEMPLATE content = index_template.render( favicon=relative_favicon_path, - title=self.config.get_index_title(), + title=self.global_config.index.title, theme=relative_theme_path, slideshows=slideshows, build_datetime=datetime.datetime.now(tz=datetime.timezone.utc), @@ -253,7 +264,7 @@ def __copy_local_files( ) -> None: links = self.__find_all_links(markdown_content) for link in links: - if self.__get_url_type(link) == URLType.RELATIVE: + if get_url_type(link) == URLType.RELATIVE: image = Path(md_file.parent, link).resolve(strict=True) self.__copy_to_output_relative_to_md_root(image, md_root_path) @@ -263,8 +274,8 @@ def __copy_theme( theme: str, default_theme_resource: Traversable | None = None, ) -> Path | str: - if self.__get_url_type(theme) == URLType.ABSOLUTE: - logger.info( + if get_url_type(theme) == URLType.ABSOLUTE: + logger.debug( f'Using theme "{theme}" from an absolute URL, no copy necessary', ) return theme @@ -276,12 +287,12 @@ def __copy_theme( default_theme_resource.joinpath(theme), ) as builtin_theme_path: theme_path = builtin_theme_path.with_suffix(".css").resolve(strict=True) - logger.info( + logger.debug( f'Using built-in theme "{theme}" from "{theme_path.absolute()}"', ) else: theme_path = Path(theme).resolve(strict=True) - logger.info(f'Using theme "{theme_path.absolute()}"') + logger.debug(f'Using theme "{theme_path.absolute()}"') theme_output_path = self.output_assets_path / theme_path.name self.__copy_to_output(theme_path, theme_output_path) @@ -294,14 +305,14 @@ def __copy_theme( return relative_theme_path def __copy_favicon(self, file_using_favicon_path: Path, favicon: str) -> Path | str: - if self.__get_url_type(favicon) == URLType.ABSOLUTE: - logger.info( + if get_url_type(favicon) == URLType.ABSOLUTE: + logger.debug( f'Using favicon "{favicon}" from an absolute URL, no copy necessary', ) return favicon favicon_path = Path(favicon).resolve(strict=True) - logger.info(f'Using favicon "{favicon_path.absolute()}"') + logger.debug(f'Using favicon "{favicon_path.absolute()}"') favicon_output_path = self.output_assets_path / favicon_path.name self.__copy_to_output(favicon_path, favicon_output_path) @@ -318,11 +329,11 @@ def __copy_favicon(self, file_using_favicon_path: Path, favicon: str) -> Path | def __create_file(self, destination_path: Path, content: Any) -> None: if destination_path.exists(): destination_path.write_text(content) - logger.info(f'Overwritten: "{destination_path}"') + logger.debug(f'Overwritten: "{destination_path}"') else: destination_path.parent.mkdir(parents=True, exist_ok=True) destination_path.write_text(content) - logger.info(f'Created file "{destination_path}"') + logger.debug(f'Created file "{destination_path}"') def __copy_to_output(self, source_path: Path, destination_path: Path) -> Path: self.__copy(source_path, destination_path) @@ -360,7 +371,7 @@ def __copy_tree(self, source_path: Path, destination_path: Path) -> None: shutil.copytree(source_path, destination_path, dirs_exist_ok=True) action = "Overwritten" if overwrite else "Copied" - logger.info( + logger.debug( f'{action} directory "{source_path.absolute()}" to "{destination_path.absolute()}"', ) @@ -370,19 +381,10 @@ def __copy_file(self, source_path: Path, destination_path: Path) -> None: shutil.copy(source_path, destination_path) action = "Overwritten" if overwrite else "Copied" - logger.info( + logger.debug( f'{action} file "{source_path.absolute()}" to "{destination_path.absolute()}"', ) - def __get_url_type(self, url: str) -> URLType: - if url.startswith("#"): - return URLType.ANCHOR - - if bool(urlparse(url).scheme): - return URLType.ABSOLUTE - - return URLType.RELATIVE - def __find_all_links(self, markdown_content: str) -> set[str]: html_content = markdown.markdown(markdown_content, extensions=["extra"]) soup = BeautifulSoup(html_content, "html.parser") diff --git a/src/mkslides/serve.py b/src/mkslides/serve.py new file mode 100644 index 0000000..ddd40a6 --- /dev/null +++ b/src/mkslides/serve.py @@ -0,0 +1,91 @@ +import logging +import shutil +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + +import livereload # type: ignore[import-untyped] +from livereload.handlers import LiveReloadHandler # type: ignore[import-untyped] +from omegaconf import DictConfig + +from mkslides.config import get_config +from mkslides.urltype import URLType + +from .build import build +from .utils import get_url_type + +logger = logging.getLogger(__name__) + +LiveReloadHandler.DEFAULT_RELOAD_TIME = ( + 0 # https://github.com/lepture/python-livereload/pull/244 +) + + +@dataclass +class ServeConfig: + dev_ip: Optional[str] = None + dev_port: Optional[str] = None + open_in_browser: bool = False + + +def determine_paths_to_watch(input_path: Path, config: DictConfig) -> list[Path]: + def should_watch(path: Optional[str]) -> Optional[Path]: + return ( + Path(path).resolve(strict=True).absolute() + if path and get_url_type(path) == URLType.RELATIVE + else None + ) + + paths_to_watch = [ + input_path, + config.internal.config_path, + should_watch(config.index.theme), + should_watch(config.index.template), + should_watch(config.slides.theme), + should_watch(config.slides.template), + ] + + return [path for path in paths_to_watch if path] + + +def serve( + config: DictConfig, + input_path: Path, + output_path: Path, + serve_config: DictConfig, +) -> None: + def reload() -> None: + logger.info("Reloading...") + config = get_config(serve_config.config_path) + build(config, input_path, output_path) + + new_paths_to_watch = determine_paths_to_watch(input_path, config) + diff_paths_to_watch = set(new_paths_to_watch) - set(paths_to_watch) + for path in diff_paths_to_watch: + logger.debug(f'Adding new watch path: "{path}"') + server.watch(filepath=path.as_posix(), func=reload, delay=1) + + build(config, input_path, output_path) + paths_to_watch = determine_paths_to_watch(input_path, config) + + try: + server = livereload.Server() + + # https://github.com/lepture/python-livereload/issues/232 + server._setup_logging = lambda: None # noqa: SLF001 + + for path in paths_to_watch: + logger.debug(f'Watching: "{path}"') + server.watch(filepath=path.as_posix(), func=reload, delay=1) + + server.serve( + host=serve_config.dev_ip, + port=serve_config.dev_port, + root=output_path, + open_url_delay=0 if serve_config.open_in_browser else None, + ) + + finally: + if output_path.exists(): + shutil.rmtree(output_path) + logger.debug(f'Removed "{output_path}"') diff --git a/src/mkslides/utils.py b/src/mkslides/utils.py new file mode 100644 index 0000000..5c37cab --- /dev/null +++ b/src/mkslides/utils.py @@ -0,0 +1,26 @@ +from urllib.parse import urlparse + +from mkslides.urltype import URLType + + +def parse_ip_port( + ip_port_str: str, +) -> tuple[str, int]: + urlparse_result = urlparse(f"//{ip_port_str}") + ip = urlparse_result.hostname + port = urlparse_result.port + + assert ip, f"Invalid IP address: {ip_port_str}" + assert port, f"Invalid port: {ip_port_str}" + + return ip, port + + +def get_url_type(url: str) -> URLType: + if url.startswith("#"): + return URLType.ANCHOR + + if bool(urlparse(url).scheme): + return URLType.ABSOLUTE + + return URLType.RELATIVE diff --git a/tests/conftest.py b/tests/conftest.py index 6c1a921..fa216dc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,15 +1,17 @@ +import shutil +import tempfile from pathlib import Path +from typing import Generator import pytest -from mkslides.config import Config -from mkslides.markupgenerator import MarkupGenerator +@pytest.fixture(scope="module") +def setup_paths() -> Generator[tuple[Path, Path], None, None]: + cwd = Path("tests").resolve(strict=True) + output_path = Path(tempfile.mkdtemp(prefix="mkslides_")).resolve(strict=False) -@pytest.fixture -def setup_markup_generator() -> tuple[MarkupGenerator, Path]: - config = Config() - output_path = Path("tests/site") - markup_generator = MarkupGenerator(config, output_path) - markup_generator.create_output_directory() - return markup_generator, output_path + yield cwd, output_path + + if output_path.exists(): + shutil.rmtree(output_path) diff --git a/tests/test_base.py b/tests/test_base.py index 1038d09..e0dd549 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -1,13 +1,16 @@ -from pathlib import Path from typing import Any -from tests.utils import assert_files_exist, assert_html_contains +from tests.utils import ( + assert_files_exist, + assert_html_contains, + run_build, + run_build_with_custom_input, +) -def test_process_directory_without_config(setup_markup_generator: Any) -> None: - markup_generator, output_path = setup_markup_generator - test_files_path = Path("tests/test_files") - markup_generator.process_markdown(test_files_path) +def test_process_directory_without_config(setup_paths: Any) -> None: + cwd, output_path = setup_paths + run_build(cwd, output_path) assert_files_exist( output_path, @@ -54,10 +57,9 @@ def test_process_directory_without_config(setup_markup_generator: Any) -> None: ) -def test_process_file_without_config(setup_markup_generator: Any) -> None: - markup_generator, output_path = setup_markup_generator - test_file_path = Path("tests/test_files/someslides.md") - markup_generator.process_markdown(test_file_path) +def test_process_file_without_config(setup_paths: Any) -> None: + cwd, output_path = setup_paths + run_build_with_custom_input(cwd, output_path, "test_files/someslides.md") assert_files_exist( output_path, diff --git a/tests/test_configs/test_absolute_url_index_favicon_path.yml b/tests/test_configs/test_absolute_url_index_favicon_path.yml new file mode 100644 index 0000000..603e7ca --- /dev/null +++ b/tests/test_configs/test_absolute_url_index_favicon_path.yml @@ -0,0 +1,2 @@ +index: + favicon: https://hogenttin.github.io/cdn/favicon/favicon.ico diff --git a/tests/test_configs/test_absolute_url_index_theme_path.yml b/tests/test_configs/test_absolute_url_index_theme_path.yml new file mode 100644 index 0000000..3d0f624 --- /dev/null +++ b/tests/test_configs/test_absolute_url_index_theme_path.yml @@ -0,0 +1,2 @@ +index: + theme: https://example.org/theme.css diff --git a/tests/test_configs/test_absolute_url_slideshow_favicon_path.yml b/tests/test_configs/test_absolute_url_slideshow_favicon_path.yml new file mode 100644 index 0000000..3c37b84 --- /dev/null +++ b/tests/test_configs/test_absolute_url_slideshow_favicon_path.yml @@ -0,0 +1,2 @@ +slides: + favicon: https://hogenttin.github.io/cdn/favicon/favicon.ico diff --git a/tests/test_configs/test_absolute_url_slideshow_theme_path.yml b/tests/test_configs/test_absolute_url_slideshow_theme_path.yml new file mode 100644 index 0000000..c65f211 --- /dev/null +++ b/tests/test_configs/test_absolute_url_slideshow_theme_path.yml @@ -0,0 +1,3 @@ +slides: + theme: https://example.org/theme.css + highlight_theme: https://example.org/highlight-theme.css diff --git a/tests/test_configs/test_builtin_slideshow_theme_path.yml b/tests/test_configs/test_builtin_slideshow_theme_path.yml new file mode 100644 index 0000000..2a04774 --- /dev/null +++ b/tests/test_configs/test_builtin_slideshow_theme_path.yml @@ -0,0 +1,3 @@ +slides: + theme: simple + highlight_theme: vs diff --git a/tests/test_configs/test_index_title.yml b/tests/test_configs/test_index_title.yml new file mode 100644 index 0000000..7941298 --- /dev/null +++ b/tests/test_configs/test_index_title.yml @@ -0,0 +1,2 @@ +index: + title: Lorem ipsum diff --git a/tests/test_configs/test_local_index_favicon_path.yml b/tests/test_configs/test_local_index_favicon_path.yml new file mode 100644 index 0000000..efff4ba --- /dev/null +++ b/tests/test_configs/test_local_index_favicon_path.yml @@ -0,0 +1,2 @@ +index: + favicon: test_styles/favicon.ico diff --git a/tests/test_configs/test_local_index_theme_path.yml b/tests/test_configs/test_local_index_theme_path.yml new file mode 100644 index 0000000..0a1e9a4 --- /dev/null +++ b/tests/test_configs/test_local_index_theme_path.yml @@ -0,0 +1,2 @@ +index: + theme: test_styles/theme.css diff --git a/tests/test_configs/test_local_slideshow_favicon_path.yml b/tests/test_configs/test_local_slideshow_favicon_path.yml new file mode 100644 index 0000000..2e390aa --- /dev/null +++ b/tests/test_configs/test_local_slideshow_favicon_path.yml @@ -0,0 +1,2 @@ +slides: + favicon: test_styles/favicon.ico diff --git a/tests/test_configs/test_local_slideshow_theme_path.yml b/tests/test_configs/test_local_slideshow_theme_path.yml new file mode 100644 index 0000000..70d6e40 --- /dev/null +++ b/tests/test_configs/test_local_slideshow_theme_path.yml @@ -0,0 +1,3 @@ +slides: + theme: test_styles/theme.css + highlight_theme: test_styles/highlight-theme.css diff --git a/tests/test_configs/test_plugins.yml b/tests/test_configs/test_plugins.yml new file mode 100644 index 0000000..f41b442 --- /dev/null +++ b/tests/test_configs/test_plugins.yml @@ -0,0 +1,6 @@ +plugins: + - name: RevealMermaid + extra_javascript: + - https://cdn.jsdelivr.net/npm/reveal.js-mermaid-plugin/plugin/mermaid/mermaid.min.js + - extra_javascript: + - https://cdn.jsdelivr.net/npm/reveal-plantuml/dist/reveal-plantuml.min.js diff --git a/tests/test_configs/test_revealjs_integer_options.yml b/tests/test_configs/test_revealjs_integer_options.yml new file mode 100644 index 0000000..5d09660 --- /dev/null +++ b/tests/test_configs/test_revealjs_integer_options.yml @@ -0,0 +1,3 @@ +revealjs: + height: 1080 + width: 1920 diff --git a/tests/test_configs/test_revealjs_markdown_data_options.yml b/tests/test_configs/test_revealjs_markdown_data_options.yml new file mode 100644 index 0000000..a7c6c7b --- /dev/null +++ b/tests/test_configs/test_revealjs_markdown_data_options.yml @@ -0,0 +1,5 @@ +slides: + charset: utf-8 + separator: ^\s*---\s*$ + separator_vertical: ^\s*-v-\s*$ + separator_notes: "^Notes?:" diff --git a/tests/test_configs/test_revealjs_string_options.yml b/tests/test_configs/test_revealjs_string_options.yml new file mode 100644 index 0000000..a3fd4b3 --- /dev/null +++ b/tests/test_configs/test_revealjs_string_options.yml @@ -0,0 +1,2 @@ +revealjs: + transition: fade diff --git a/tests/test_emojis.py b/tests/test_emojis.py index 768dcc9..abf59fa 100644 --- a/tests/test_emojis.py +++ b/tests/test_emojis.py @@ -1,13 +1,11 @@ -from pathlib import Path from typing import Any -from tests.utils import assert_html_contains +from tests.utils import assert_html_contains, run_build -def test_emojize(setup_markup_generator: Any) -> None: - markup_generator, output_path = setup_markup_generator - test_files_path = Path("tests/test_files") - markup_generator.process_markdown(test_files_path) +def test_emojize(setup_paths: Any) -> None: + cwd, output_path = setup_paths + run_build(cwd, output_path) assert_html_contains( output_path / "someslides.html", diff --git a/tests/test_index.py b/tests/test_index.py index 42b795e..f724a03 100644 --- a/tests/test_index.py +++ b/tests/test_index.py @@ -1,21 +1,11 @@ -from pathlib import Path from typing import Any -from tests.utils import assert_html_contains +from tests.utils import assert_html_contains, run_build_with_config -def test_index_title(setup_markup_generator: Any) -> None: - markup_generator, output_path = setup_markup_generator - markup_generator.config.merge_config_from_dict( - { - "index": { - "title": "Lorem ipsum", - }, - }, - ) - - test_files_path = Path("tests/test_files") - markup_generator.process_markdown(test_files_path) +def test_index_title(setup_paths: Any) -> None: + cwd, output_path = setup_paths + run_build_with_config(cwd, output_path, "test_index_title.yml") assert_html_contains( output_path / "index.html", diff --git a/tests/test_markdown_data_options.py b/tests/test_markdown_data_options.py index d5e7189..bcd05a7 100644 --- a/tests/test_markdown_data_options.py +++ b/tests/test_markdown_data_options.py @@ -1,25 +1,12 @@ import re -from pathlib import Path from typing import Any -from tests.utils import assert_html_contains_regexp +from tests.utils import assert_html_contains_regexp, run_build_with_config -def test_revealjs_markdown_data_options(setup_markup_generator: Any) -> None: - markup_generator, output_path = setup_markup_generator - markup_generator.config.merge_config_from_dict( - { - "slides": { - "charset": "utf-8", - "separator": r"^\s*---\s*$", - "separator_vertical": r"^\s*-v-\s*$", - "separator_notes": r"^Notes?:", - }, - }, - ) - - test_files_path = Path("tests/test_files") - markup_generator.process_markdown(test_files_path) +def test_revealjs_markdown_data_options(setup_paths: Any) -> None: + cwd, output_path = setup_paths + run_build_with_config(cwd, output_path, "test_revealjs_markdown_data_options.yml") assert_html_contains_regexp( output_path / "someslides.html", diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 0991ec4..428b67e 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -1,32 +1,16 @@ import re -from pathlib import Path from typing import Any -from tests.utils import assert_html_contains, assert_html_contains_regexp +from tests.utils import ( + assert_html_contains, + assert_html_contains_regexp, + run_build_with_config, +) -def test_plugins(setup_markup_generator: Any) -> None: - markup_generator, output_path = setup_markup_generator - markup_generator.config.merge_config_from_dict( - { - "plugins": [ - { - "name": "RevealMermaid", - "extra_javascript": [ - "https://cdn.jsdelivr.net/npm/reveal.js-mermaid-plugin/plugin/mermaid/mermaid.min.js", - ], - }, - { - "extra_javascript": [ - "https://cdn.jsdelivr.net/npm/reveal-plantuml/dist/reveal-plantuml.min.js", - ], - }, - ], - }, - ) - - test_files_path = Path("tests/test_files") - markup_generator.process_markdown(test_files_path) +def test_plugins(setup_paths: Any) -> None: + cwd, output_path = setup_paths + run_build_with_config(cwd, output_path, "test_plugins.yml") assert_html_contains( output_path / "someslides.html", diff --git a/tests/test_revealjs_options.py b/tests/test_revealjs_options.py index c161dc5..25c6c67 100644 --- a/tests/test_revealjs_options.py +++ b/tests/test_revealjs_options.py @@ -1,16 +1,18 @@ import re -from pathlib import Path from typing import Any -from tests.utils import assert_html_contains, assert_html_contains_regexp +from tests.utils import ( + assert_html_contains, + assert_html_contains_regexp, + run_build, + run_build_with_config, +) # Necessary for livereload to work properly -def test_revealjs_default_options(setup_markup_generator: Any) -> None: - markup_generator, output_path = setup_markup_generator - - test_files_path = Path("tests/test_files") - markup_generator.process_markdown(test_files_path) +def test_revealjs_default_options(setup_paths: Any) -> None: + cwd, output_path = setup_paths + run_build(cwd, output_path) assert_html_contains( output_path / "someslides.html", @@ -20,19 +22,9 @@ def test_revealjs_default_options(setup_markup_generator: Any) -> None: ) -def test_revealjs_integer_options(setup_markup_generator: Any) -> None: - markup_generator, output_path = setup_markup_generator - markup_generator.config.merge_config_from_dict( - { - "revealjs": { - "height": 1080, - "width": 1920, - }, - }, - ) - - test_files_path = Path("tests/test_files") - markup_generator.process_markdown(test_files_path) +def test_revealjs_integer_options(setup_paths: Any) -> None: + cwd, output_path = setup_paths + run_build_with_config(cwd, output_path, "test_revealjs_integer_options.yml") assert_html_contains_regexp( output_path / "someslides.html", @@ -51,18 +43,9 @@ def test_revealjs_integer_options(setup_markup_generator: Any) -> None: ) -def test_revealjs_string_options(setup_markup_generator: Any) -> None: - markup_generator, output_path = setup_markup_generator - markup_generator.config.merge_config_from_dict( - { - "revealjs": { - "transition": "fade", - }, - }, - ) - - test_files_path = Path("tests/test_files") - markup_generator.process_markdown(test_files_path) +def test_revealjs_string_options(setup_paths: Any) -> None: + cwd, output_path = setup_paths + run_build_with_config(cwd, output_path, "test_revealjs_string_options.yml") assert_html_contains_regexp( output_path / "someslides.html", diff --git a/tests/test_themes.py b/tests/test_themes.py index 07ab2e0..589c405 100644 --- a/tests/test_themes.py +++ b/tests/test_themes.py @@ -1,22 +1,11 @@ -from pathlib import Path from typing import Any -from tests.utils import assert_files_exist, assert_html_contains +from tests.utils import assert_files_exist, assert_html_contains, run_build_with_config -def test_local_slideshow_theme_path(setup_markup_generator: Any) -> None: - markup_generator, output_path = setup_markup_generator - markup_generator.config.merge_config_from_dict( - { - "slides": { - "theme": "tests/test_styles/theme.css", - "highlight_theme": "tests/test_styles/highlight-theme.css", - }, - }, - ) - - test_files_path = Path("tests/test_files") - markup_generator.process_markdown(test_files_path) +def test_local_slideshow_theme_path(setup_paths: Any) -> None: + cwd, output_path = setup_paths + run_build_with_config(cwd, output_path, "test_local_slideshow_theme_path.yml") assert_html_contains( output_path / "someslides.html", @@ -37,20 +26,14 @@ def test_local_slideshow_theme_path(setup_markup_generator: Any) -> None: ) -def test_absolute_url_slideshow_theme_path(setup_markup_generator: Any) -> None: - markup_generator, output_path = setup_markup_generator - markup_generator.config.merge_config_from_dict( - { - "slides": { - "theme": "https://example.org/theme.css", - "highlight_theme": "https://example.org/highlight-theme.css", - }, - }, +def test_absolute_url_slideshow_theme_path(setup_paths: Any) -> None: + cwd, output_path = setup_paths + run_build_with_config( + cwd, + output_path, + "test_absolute_url_slideshow_theme_path.yml", ) - test_files_path = Path("tests/test_files") - markup_generator.process_markdown(test_files_path) - assert_html_contains( output_path / "someslides.html", [ @@ -70,19 +53,9 @@ def test_absolute_url_slideshow_theme_path(setup_markup_generator: Any) -> None: ) -def test_builtin_slideshow_theme_path(setup_markup_generator: Any) -> None: - markup_generator, output_path = setup_markup_generator - markup_generator.config.merge_config_from_dict( - { - "slides": { - "theme": "simple", - "highlight_theme": "vs", - }, - }, - ) - - test_files_path = Path("tests/test_files") - markup_generator.process_markdown(test_files_path) +def test_builtin_slideshow_theme_path(setup_paths: Any) -> None: + cwd, output_path = setup_paths + run_build_with_config(cwd, output_path, "test_builtin_slideshow_theme_path.yml") assert_files_exist( output_path, @@ -110,18 +83,9 @@ def test_builtin_slideshow_theme_path(setup_markup_generator: Any) -> None: ) -def test_local_index_theme_path(setup_markup_generator: Any) -> None: - markup_generator, output_path = setup_markup_generator - markup_generator.config.merge_config_from_dict( - { - "index": { - "theme": "tests/test_styles/theme.css", - }, - }, - ) - - test_files_path = Path("tests/test_files") - markup_generator.process_markdown(test_files_path) +def test_local_index_theme_path(setup_paths: Any) -> None: + cwd, output_path = setup_paths + run_build_with_config(cwd, output_path, "test_local_index_theme_path.yml") assert_html_contains( output_path / "index.html", @@ -131,18 +95,9 @@ def test_local_index_theme_path(setup_markup_generator: Any) -> None: ) -def test_absolute_url_index_theme_path(setup_markup_generator: Any) -> None: - markup_generator, output_path = setup_markup_generator - markup_generator.config.merge_config_from_dict( - { - "index": { - "theme": "https://example.org/theme.css", - }, - }, - ) - - test_files_path = Path("tests/test_files") - markup_generator.process_markdown(test_files_path) +def test_absolute_url_index_theme_path(setup_paths: Any) -> None: + cwd, output_path = setup_paths + run_build_with_config(cwd, output_path, "test_absolute_url_index_theme_path.yml") assert_html_contains( output_path / "index.html", @@ -152,18 +107,9 @@ def test_absolute_url_index_theme_path(setup_markup_generator: Any) -> None: ) -def test_absolute_url_index_favicon_path(setup_markup_generator: Any) -> None: - markup_generator, output_path = setup_markup_generator - markup_generator.config.merge_config_from_dict( - { - "index": { - "favicon": "https://hogenttin.github.io/cdn/favicon/favicon.ico", - }, - }, - ) - - test_files_path = Path("tests/test_files") - markup_generator.process_markdown(test_files_path) +def test_absolute_url_index_favicon_path(setup_paths: Any) -> None: + cwd, output_path = setup_paths + run_build_with_config(cwd, output_path, "test_absolute_url_index_favicon_path.yml") assert_html_contains( output_path / "index.html", @@ -173,19 +119,14 @@ def test_absolute_url_index_favicon_path(setup_markup_generator: Any) -> None: ) -def test_absolute_url_slideshow_favicon_path(setup_markup_generator: Any) -> None: - markup_generator, output_path = setup_markup_generator - markup_generator.config.merge_config_from_dict( - { - "slides": { - "favicon": "https://hogenttin.github.io/cdn/favicon/favicon.ico", - }, - }, +def test_absolute_url_slideshow_favicon_path(setup_paths: Any) -> None: + cwd, output_path = setup_paths + run_build_with_config( + cwd, + output_path, + "test_absolute_url_slideshow_favicon_path.yml", ) - test_files_path = Path("tests/test_files") - markup_generator.process_markdown(test_files_path) - assert_html_contains( output_path / "someslides.html", [ @@ -201,18 +142,9 @@ def test_absolute_url_slideshow_favicon_path(setup_markup_generator: Any) -> Non ) -def test_local_index_favicon_path(setup_markup_generator: Any) -> None: - markup_generator, output_path = setup_markup_generator - markup_generator.config.merge_config_from_dict( - { - "index": { - "favicon": "tests/test_styles/favicon.ico", - }, - }, - ) - - test_files_path = Path("tests/test_files") - markup_generator.process_markdown(test_files_path) +def test_local_index_favicon_path(setup_paths: Any) -> None: + cwd, output_path = setup_paths + run_build_with_config(cwd, output_path, "test_local_index_favicon_path.yml") assert_html_contains( output_path / "index.html", @@ -222,18 +154,9 @@ def test_local_index_favicon_path(setup_markup_generator: Any) -> None: ) -def test_local_slideshow_favicon_path(setup_markup_generator: Any) -> None: - markup_generator, output_path = setup_markup_generator - markup_generator.config.merge_config_from_dict( - { - "slides": { - "favicon": "tests/test_styles/favicon.ico", - }, - }, - ) - - test_files_path = Path("tests/test_files") - markup_generator.process_markdown(test_files_path) +def test_local_slideshow_favicon_path(setup_paths: Any) -> None: + cwd, output_path = setup_paths + run_build_with_config(cwd, output_path, "test_local_slideshow_favicon_path.yml") assert_html_contains( output_path / "someslides.html", diff --git a/tests/utils.py b/tests/utils.py index 461ab2c..41dfbe5 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,10 +1,49 @@ +import subprocess from pathlib import Path from re import Pattern +def run_build_with_custom_input( + cwd: Path, + output_path: Path, + input_filename: str, +) -> subprocess.CompletedProcess[str]: + result = subprocess.run( + ["mkslides", "-v", "build", "-d", output_path, input_filename], + cwd=cwd, + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr + return result + + +def run_build(cwd: Path, output_path: Path) -> subprocess.CompletedProcess[str]: + return run_build_with_custom_input(cwd, output_path, "test_files") + + +def run_build_with_config( + cwd: Path, + output_path: Path, + config_filename: str, +) -> subprocess.CompletedProcess[str]: + config_path = (cwd / "test_configs" / config_filename).resolve(strict=True) + result = subprocess.run( + ["mkslides", "-v", "build", "-d", output_path, "-f", config_path, "test_files"], + cwd=cwd, + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr + return result + + def assert_files_exist(output_path: Path, files: list[str]) -> None: for file in files: - assert (output_path / file).exists(), f"{file} does not exist" + file_path = (output_path / file).absolute() + assert file_path.exists(), f"{file_path} does not exist" def assert_html_contains(file_path: Path, expected_content: list[str]) -> None: