From 3dbb95bc0be098d73e5fae65a76ebaa7cf4a3fbe Mon Sep 17 00:00:00 2001 From: Martijn Saelens Date: Sun, 24 Nov 2024 23:56:32 +0100 Subject: [PATCH 1/9] Refactored entry logic --- assets/mkslides.default.yml | 8 -- poetry.lock | 27 ++++- pyproject.toml | 1 + src/mkslides/__main__.py | 165 +++++++++------------------- src/mkslides/build.py | 15 +++ src/mkslides/config.py | 186 +++++++++++++------------------- src/mkslides/constants.py | 1 - src/mkslides/markupgenerator.py | 70 ++++++------ src/mkslides/serve.py | 114 ++++++++++++++++++++ src/mkslides/utils.py | 14 +++ tests/conftest.py | 2 +- 11 files changed, 327 insertions(+), 276 deletions(-) delete mode 100644 assets/mkslides.default.yml create mode 100644 src/mkslides/build.py create mode 100644 src/mkslides/serve.py create mode 100644 src/mkslides/utils.py 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..4e032d4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,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" diff --git a/src/mkslides/__main__.py b/src/mkslides/__main__.py index ba0910a..106a68c 100644 --- a/src/mkslides/__main__.py +++ b/src/mkslides/__main__.py @@ -1,15 +1,14 @@ import logging -import shutil +import sys 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 rich.logging import RichHandler -from .config import Config +from mkslides.build import execute_build_command +from mkslides.serve import execute_serve_command + from .constants import ( DEFAULT_OUTPUT_DIR, EXPECTED_CONFIG_LOCATION, @@ -17,26 +16,26 @@ 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", + "type": click.Path(exists=True, resolve_path=True, path_type=Path), "default": EXPECTED_CONFIG_LOCATION, "help": "Provide a specific MkSlides-Reveal config file.", } @@ -50,39 +49,22 @@ 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) -> None: """MkSlides - Slides with Markdown using the power of Reveal.js.""" - -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 + if verbose: + logger.setLevel("DEBUG") + logger.debug("Verbose output enabled") -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) +# Build Command ################################################################ @cli.command() @@ -91,22 +73,25 @@ def generate(config_file: str, input_path: Path, output_directory: Path) -> None @click.option( "-d", "--site-dir", + type=click.Path(exists=True, resolve_path=True, 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(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") + + output_path = Path(site_dir).resolve(strict=False) - input_path = Path(files).resolve(strict=True) - output_directory = Path(site_dir).resolve(strict=False) + execute_build_command(config_file, files, output_path) - generate(config_file, input_path, output_directory) + +# Serve Command ################################################################ @cli.command() @@ -147,88 +132,38 @@ def build(files: str, config_file: str, site_dir: str) -> None: ) @click.option("-f", "--config-file", **config_file_argument_data) # type: ignore[arg-type] def serve( # noqa: C901 - files: str, + files: Path, 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, + config_file: Path | None, ) -> 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") + + output_path = Path(tempfile.mkdtemp(prefix="mkslides_")).resolve(strict=False) + + execute_serve_command( + config_file, + files, + output_path, + dev_addr, + open_in_browser, + watch_index_theme, + watch_index_template, + watch_slides_theme, + watch_slides_template, + ) +################################################################################ + if __name__ == "__main__": cli() diff --git a/src/mkslides/build.py b/src/mkslides/build.py new file mode 100644 index 0000000..83fdcbc --- /dev/null +++ b/src/mkslides/build.py @@ -0,0 +1,15 @@ +import logging +from pathlib import Path +from mkslides.config import get_config +from mkslides.markupgenerator import MarkupGenerator + +logger = logging.getLogger(__name__) + + +def execute_build_command( + config_path: Path, input_path: Path, output_path: Path +) -> None: + config = get_config(config_path) + 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..b11d4ee 100644 --- a/src/mkslides/config.py +++ b/src/mkslides/config.py @@ -1,122 +1,80 @@ +from dataclasses import dataclass, field import logging from pathlib import Path -from typing import Any +import sys +from typing import Any, Dict, Optional -import yaml - -from .constants import DEFAULT_CONFIG_RESOURCE +from omegaconf import MISSING, DictConfig, OmegaConf logger = logging.getLogger(__name__) +@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: Optional[str] = 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) + + +def validate_config(config: Config) -> None: + # index.favicon + # index.theme + # index.template + # slides.favicon + # slides.highlight_theme + # slides.theme + # slides.template + + return True + + +def load_config_file(config_file: Path) -> DictConfig: + config = OmegaConf.structured(Config) + + # config = OmegaConf.merge( + # config, + # OmegaConf.load(config_file), + # ) + # logger.info(f"Loaded config from {config_file}") + + assert OmegaConf.is_dict(config) + + if not validate_config(config): + raise ValueError("Invalid config") + + OmegaConf.set_readonly(conf=config, value=True) + logger.debug(f"Used config: {config}") + + return config diff --git a/src/mkslides/constants.py b/src/mkslides/constants.py index e72be9d..fea8e50 100644 --- a/src/mkslides/constants.py +++ b/src/mkslides/constants.py @@ -18,7 +18,6 @@ DEFAULT_OUTPUT_DIR = "site" ASSETS_RESOURCE = resources.files("assets") -DEFAULT_CONFIG_RESOURCE = ASSETS_RESOURCE.joinpath("mkslides.default.yml") REVEALJS_RESOURCE = ASSETS_RESOURCE.joinpath("reveal.js") REVEALJS_THEMES_RESOURCE = REVEALJS_RESOURCE.joinpath("dist", "theme") HIGHLIGHTJS_RESOURCE = ASSETS_RESOURCE.joinpath("highlight.js") diff --git a/src/mkslides/markupgenerator.py b/src/mkslides/markupgenerator.py index f52f5fd..5cf592b 100644 --- a/src/mkslides/markupgenerator.py +++ b/src/mkslides/markupgenerator.py @@ -13,6 +13,7 @@ from bs4 import BeautifulSoup, Comment from emoji import emojize from natsort import natsorted +from omegaconf import DictConfig from .config import Config from .constants import ( @@ -32,18 +33,15 @@ class MarkupGenerator: def __init__( self, - config: Config, + config: DictConfig, output_directory_path: Path, ) -> None: - # Config self.config = config - # Paths - 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 +53,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 +80,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,7 +99,7 @@ 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 @@ -121,12 +119,12 @@ def __process_markdown_file( walk_up=True, ) - revealjs_config = self.config.get_revealjs_options() + revealjs_config = self.config.revealjs # Copy the theme CSS relative_theme_path = None - if theme := self.config.get_slides_theme(): + if theme := self.config.slides.theme: relative_theme_path = self.__copy_theme( output_markup_path, theme, @@ -136,7 +134,7 @@ def __process_markdown_file( # Copy the highlight CSS relative_highlight_theme_path = None - if theme := self.config.get_slides_highlight_theme(): + if theme := self.config.slides.highlight_theme: relative_highlight_theme_path = self.__copy_theme( output_markup_path, theme, @@ -146,18 +144,18 @@ def __process_markdown_file( # Copy the favicon relative_favicon_path = None - if favicon := self.config.get_slides_favicon(): + if favicon := self.config.slides.favicon: relative_favicon_path = self.__copy_favicon(output_markup_path, favicon) # Retrieve the 3rd party plugins - plugins = self.config.get_plugins() + plugins = self.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 := self.config.slides.template: slideshow_template = LOCAL_JINJA2_ENVIRONMENT.get_template(template_config) else: slideshow_template = DEFAULT_SLIDESHOW_TEMPLATE @@ -166,10 +164,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": self.config.slides.separator, + "data-separator-vertical": self.config.slides.separator_vertical, + "data-separator-notes": self.config.slides.separator_notes, + "data-charset": self.config.slides.charset, }.items() if value } @@ -194,7 +192,7 @@ def __process_markdown_file( 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( @@ -213,32 +211,32 @@ def __process_markdown_directory(self, md_root_path: Path) -> None: slideshows = natsorted(slideshows, key=lambda x: 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.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.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.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.config.index.title, theme=relative_theme_path, slideshows=slideshows, build_datetime=datetime.datetime.now(tz=datetime.timezone.utc), @@ -264,7 +262,7 @@ def __copy_theme( default_theme_resource: Traversable | None = None, ) -> Path | str: if self.__get_url_type(theme) == URLType.ABSOLUTE: - logger.info( + logger.debug( f'Using theme "{theme}" from an absolute URL, no copy necessary', ) return theme @@ -276,12 +274,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) @@ -295,13 +293,13 @@ def __copy_theme( def __copy_favicon(self, file_using_favicon_path: Path, favicon: str) -> Path | str: if self.__get_url_type(favicon) == URLType.ABSOLUTE: - logger.info( + 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 +316,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 +358,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,7 +368,7 @@ 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()}"', ) diff --git a/src/mkslides/serve.py b/src/mkslides/serve.py new file mode 100644 index 0000000..d4779bf --- /dev/null +++ b/src/mkslides/serve.py @@ -0,0 +1,114 @@ +import logging +from pathlib import Path +import shutil + +import livereload # type: ignore[import-untyped] +from livereload.handlers import LiveReloadHandler # type: ignore[import-untyped] + +from mkslides.build import execute_build_command +from mkslides.config import get_config +from mkslides.markupgenerator import MarkupGenerator +from mkslides.utils import parse_ip_port + +logger = logging.getLogger(__name__) + +LiveReloadHandler.DEFAULT_RELOAD_TIME = ( + 0 # https://github.com/lepture/python-livereload/pull/244 +) + + +class LiveReloadHandler: + def __init__( + self, # noqa: C901 + config_path: Path | None, + input_path: Path, + output_path: Path, + dev_addr: str, + open_in_browser: bool, + watch_index_theme: bool, + watch_index_template: bool, + watch_slides_theme: bool, + watch_slides_template: bool, + ): + self.config_path = config_path + self.input_path = input_path + self.output_path = output_path + self.dev_addr = dev_addr + self.open_in_browser = open_in_browser + self.watch_index_theme = watch_index_theme + self.watch_index_template = watch_index_template + self.watch_slides_theme = watch_slides_theme + self.watch_slides_template = watch_slides_template + + def determine_watched_paths(self, config): + watched_paths = [ + self.input_path, + self.config_path, + config.index.theme if self.watch_index_theme else None, + config.index.template if self.watch_index_template else None, + config.slides.theme if self.watch_slides_theme else None, + config.slides.template if self.watch_slides_template else None, + ] + + for path in watched_paths: + if path: + resolved_path = Path(path).resolve(strict=True).absolute() + logger.debug(f'Watching: "{resolved_path}"') + server.watch(filepath=resolved_path.as_posix(), func=reload, delay=1) + + def generate_slides(self): + self.config = get_config(config_path) + markup_generator = MarkupGenerator(config, output_path) + markup_generator.create_or_clear_output_directory() + markup_generator.process_markdown(input_path) + + def serve(): + try: + server = livereload.Server() + + # https://github.com/lepture/python-livereload/issues/232 + server._setup_logging = lambda: None # noqa: SLF001 + + watched_paths = [ + input_path, + config_path if config_path.exists() else None, + # config.index.theme if watch_index_theme else None, + # config.index.template if watch_index_template else None, + # config.slides.theme if watch_slides_theme else None, + # config.slides.template if watch_slides_template else None, + ] + + for path in watched_paths: + if path: + resolved_path = Path(path).resolve(strict=True).absolute() + logger.debug(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_path, + open_url_delay=0 if open_in_browser else None, + ) + + finally: + if output_path.exists(): + shutil.rmtree(output_path) + logger.debug(f'Removed "{output_path}"') + + +# def execute_serve_command( # noqa: C901 +# config_path: Path, +# input_path: Path, +# output_path: Path, +# dev_addr: str, +# open_in_browser: bool, +# watch_index_theme: bool, +# watch_index_template: bool, +# watch_slides_theme: bool, +# watch_slides_template: bool, +# ) -> None: diff --git a/src/mkslides/utils.py b/src/mkslides/utils.py new file mode 100644 index 0000000..4f1817d --- /dev/null +++ b/src/mkslides/utils.py @@ -0,0 +1,14 @@ +from urllib.parse import urlparse + + +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 diff --git a/tests/conftest.py b/tests/conftest.py index 6c1a921..a3c710e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,5 +11,5 @@ 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() + markup_generator.create_or_clear_output_directory() return markup_generator, output_path From f4f4964a6cd20e0e254bb05c27aa864f2cbbb005 Mon Sep 17 00:00:00 2001 From: Martijn Saelens Date: Mon, 25 Nov 2024 16:39:33 +0100 Subject: [PATCH 2/9] Explained example config file --- README.md | 37 +++++++++++++++++++++++++++++++------ 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 92d530d..8f0a334 100644 --- a/README.md +++ b/README.md @@ -63,38 +63,63 @@ 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): plugin id string + # (see https://revealjs.com/creating-plugins/#registering-a-plugin) - name: RevealMermaid + # JavaScript file of the plugin: file path or public url to JavaScript + # file 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/). - ## Contributing You can run the tests with `poetry` and `pytest`: From 93067f454fe35b8a340b5b00e26f4c6e01e7b066 Mon Sep 17 00:00:00 2001 From: Martijn Saelens Date: Mon, 25 Nov 2024 18:19:34 +0100 Subject: [PATCH 3/9] Added validation --- src/mkslides/config.py | 75 ++++++++++++++++++++++++--------- src/mkslides/constants.py | 31 ++++++++------ src/mkslides/markupgenerator.py | 16 ++----- src/mkslides/utils.py | 12 ++++++ 4 files changed, 89 insertions(+), 45 deletions(-) diff --git a/src/mkslides/config.py b/src/mkslides/config.py index b11d4ee..68cb9fc 100644 --- a/src/mkslides/config.py +++ b/src/mkslides/config.py @@ -4,6 +4,9 @@ import sys from typing import Any, Dict, Optional +from mkslides.constants import HIGHLIGHTJS_THEMES_LIST, REVEALJS_THEMES_LIST +from mkslides.urltype import URLType +from mkslides.utils import get_url_type from omegaconf import MISSING, DictConfig, OmegaConf logger = logging.getLogger(__name__) @@ -48,32 +51,62 @@ class Config: plugins: list[Plugin] = field(default_factory=list) -def validate_config(config: Config) -> None: - # index.favicon - # index.theme - # index.template - # slides.favicon - # slides.highlight_theme - # slides.theme - # slides.template - - return True - - -def load_config_file(config_file: Path) -> DictConfig: +def validate(config) -> 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 not config.index.theme 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 not config.slides.highlight_theme 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: + if get_url_type(config.slides.theme) == URLType.RELATIVE: + if config.slides.theme in REVEALJS_THEMES_LIST: + pass + else: + Path(config.slides.theme).resolve(strict=True) + + +def get_config(config_file: Path | None = None) -> DictConfig: config = OmegaConf.structured(Config) - # config = OmegaConf.merge( - # config, - # OmegaConf.load(config_file), - # ) - # logger.info(f"Loaded config from {config_file}") + if config_file: + config = OmegaConf.merge( + config, + OmegaConf.load(config_file), + ) + logger.info(f"Loaded config from {config_file}") assert OmegaConf.is_dict(config) - if not validate_config(config): - raise ValueError("Invalid config") - OmegaConf.set_readonly(conf=config, value=True) logger.debug(f"Used config: {config}") diff --git a/src/mkslides/constants.py b/src/mkslides/constants.py index fea8e50..33ac788 100644 --- a/src/mkslides/constants.py +++ b/src/mkslides/constants.py @@ -14,14 +14,33 @@ re.VERBOSE, ) +VERSION = metadata.version("mkslides") EXPECTED_CONFIG_LOCATION = "mkslides.yml" DEFAULT_OUTPUT_DIR = "site" ASSETS_RESOURCE = resources.files("assets") + REVEALJS_RESOURCE = ASSETS_RESOURCE.joinpath("reveal.js") REVEALJS_THEMES_RESOURCE = REVEALJS_RESOURCE.joinpath("dist", "theme") +REVEALJS_THEMES_LIST = [ + theme.stem for theme in REVEALJS_THEMES_RESOURCE.iterdir() + if theme.is_file() and theme.suffix == ".css" +] +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 = [ + theme.stem for theme in HIGHLIGHTJS_THEMES_RESOURCE.iterdir() + if theme.is_file() and theme.suffix == ".css" +] +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"), @@ -32,15 +51,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 5cf592b..58297da 100644 --- a/src/mkslides/markupgenerator.py +++ b/src/mkslides/markupgenerator.py @@ -13,6 +13,7 @@ from bs4 import BeautifulSoup, Comment from emoji import emojize from natsort import natsorted +from mkslides.utils import get_url_type from omegaconf import DictConfig from .config import Config @@ -251,7 +252,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) @@ -261,7 +262,7 @@ def __copy_theme( theme: str, default_theme_resource: Traversable | None = None, ) -> Path | str: - if self.__get_url_type(theme) == URLType.ABSOLUTE: + if get_url_type(theme) == URLType.ABSOLUTE: logger.debug( f'Using theme "{theme}" from an absolute URL, no copy necessary', ) @@ -292,7 +293,7 @@ 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: + if get_url_type(favicon) == URLType.ABSOLUTE: logger.debug( f'Using favicon "{favicon}" from an absolute URL, no copy necessary', ) @@ -372,15 +373,6 @@ def __copy_file(self, source_path: Path, destination_path: Path) -> None: 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) soup = BeautifulSoup(html_content, "html.parser") diff --git a/src/mkslides/utils.py b/src/mkslides/utils.py index 4f1817d..5c37cab 100644 --- a/src/mkslides/utils.py +++ b/src/mkslides/utils.py @@ -1,5 +1,7 @@ from urllib.parse import urlparse +from mkslides.urltype import URLType + def parse_ip_port( ip_port_str: str, @@ -12,3 +14,13 @@ def parse_ip_port( 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 From b099d51c513e0b47ee763783e556567aef4748cd Mon Sep 17 00:00:00 2001 From: Martijn Saelens Date: Mon, 25 Nov 2024 18:31:41 +0100 Subject: [PATCH 4/9] Renamed commands --- src/mkslides/__main__.py | 35 +++++++++++++++++------------------ src/mkslides/build.py | 2 +- src/mkslides/config.py | 1 - 3 files changed, 18 insertions(+), 20 deletions(-) diff --git a/src/mkslides/__main__.py b/src/mkslides/__main__.py index 106a68c..e6d937f 100644 --- a/src/mkslides/__main__.py +++ b/src/mkslides/__main__.py @@ -6,8 +6,7 @@ import click from rich.logging import RichHandler -from mkslides.build import execute_build_command -from mkslides.serve import execute_serve_command +from .build import build from .constants import ( DEFAULT_OUTPUT_DIR, @@ -67,7 +66,7 @@ def cli(verbose) -> None: # Build Command ################################################################ -@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( @@ -78,7 +77,7 @@ def cli(verbose) -> None: metavar="PATH", default=DEFAULT_OUTPUT_DIR, ) -def build(files: Path, config_file: Path | None, site_dir: str) -> None: +def build_command(files: Path, config_file: Path | None, site_dir: str) -> None: """ Build the MkDocs documentation. @@ -88,13 +87,13 @@ def build(files: Path, config_file: Path | None, site_dir: str) -> None: output_path = Path(site_dir).resolve(strict=False) - execute_build_command(config_file, files, output_path) + build(config_file, files, output_path) # Serve Command ################################################################ -@cli.command() +@cli.command(name="serve") @click.argument("files", **files_argument_data) # type: ignore[arg-type] @click.option( "-a", @@ -131,7 +130,7 @@ def build(files: Path, config_file: Path | None, site_dir: str) -> None: is_flag=True, ) @click.option("-f", "--config-file", **config_file_argument_data) # type: ignore[arg-type] -def serve( # noqa: C901 +def serve_command( # noqa: C901 files: Path, dev_addr: str, open_in_browser: bool, @@ -150,17 +149,17 @@ def serve( # noqa: C901 output_path = Path(tempfile.mkdtemp(prefix="mkslides_")).resolve(strict=False) - execute_serve_command( - config_file, - files, - output_path, - dev_addr, - open_in_browser, - watch_index_theme, - watch_index_template, - watch_slides_theme, - watch_slides_template, - ) + # execute_serve_command( + # config_file, + # files, + # output_path, + # dev_addr, + # open_in_browser, + # watch_index_theme, + # watch_index_template, + # watch_slides_theme, + # watch_slides_template, + # ) ################################################################################ diff --git a/src/mkslides/build.py b/src/mkslides/build.py index 83fdcbc..17eb967 100644 --- a/src/mkslides/build.py +++ b/src/mkslides/build.py @@ -6,7 +6,7 @@ logger = logging.getLogger(__name__) -def execute_build_command( +def build( config_path: Path, input_path: Path, output_path: Path ) -> None: config = get_config(config_path) diff --git a/src/mkslides/config.py b/src/mkslides/config.py index 68cb9fc..89c3409 100644 --- a/src/mkslides/config.py +++ b/src/mkslides/config.py @@ -107,7 +107,6 @@ def get_config(config_file: Path | None = None) -> DictConfig: assert OmegaConf.is_dict(config) - OmegaConf.set_readonly(conf=config, value=True) logger.debug(f"Used config: {config}") return config From 2d80c39026b393287c7bbd7e032a602a118a05af Mon Sep 17 00:00:00 2001 From: Martijn Saelens Date: Mon, 25 Nov 2024 19:08:48 +0100 Subject: [PATCH 5/9] Added debug output config --- src/mkslides/__main__.py | 41 ++++---- src/mkslides/build.py | 5 +- src/mkslides/config.py | 24 +++-- src/mkslides/constants.py | 3 +- src/mkslides/markupgenerator.py | 2 +- src/mkslides/serve.py | 168 ++++++++++++-------------------- 6 files changed, 111 insertions(+), 132 deletions(-) diff --git a/src/mkslides/__main__.py b/src/mkslides/__main__.py index e6d937f..766ffbe 100644 --- a/src/mkslides/__main__.py +++ b/src/mkslides/__main__.py @@ -7,10 +7,9 @@ from rich.logging import RichHandler from .build import build - +from .config import get_config from .constants import ( DEFAULT_OUTPUT_DIR, - EXPECTED_CONFIG_LOCATION, HIGHLIGHTJS_THEMES_VERSION, REVEALJS_VERSION, VERSION, @@ -35,7 +34,6 @@ config_file_argument_data = { "metavar": "FILENAME", "type": click.Path(exists=True, resolve_path=True, path_type=Path), - "default": EXPECTED_CONFIG_LOCATION, "help": "Provide a specific MkSlides-Reveal config file.", } @@ -85,9 +83,10 @@ def build_command(files: Path, config_file: Path | None, site_dir: str) -> None: """ logger.debug("Command: build") + config = get_config(config_file) output_path = Path(site_dir).resolve(strict=False) - build(config_file, files, output_path) + build(config, files, output_path) # Serve Command ################################################################ @@ -95,6 +94,7 @@ def build_command(files: Path, config_file: Path | None, site_dir: str) -> None: @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", @@ -129,16 +129,15 @@ def build_command(files: Path, config_file: Path | None, site_dir: str) -> None: 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_command( # noqa: C901 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: Path | None, ) -> None: """ Run the builtin development server. @@ -147,19 +146,27 @@ def serve_command( # noqa: C901 """ logger.debug("Command: serve") + config = get_config(config_file) + config.merge_with( + { + "serve": { + "config_file": config_file, + "dev_addr": dev_addr, + "open_in_browser": open_in_browser, + "watch_index_theme": watch_index_theme, + "watch_index_template": watch_index_template, + "watch_slides_theme": watch_slides_theme, + "watch_slides_template": watch_slides_template, + } + } + ) output_path = Path(tempfile.mkdtemp(prefix="mkslides_")).resolve(strict=False) - # execute_serve_command( - # config_file, - # files, - # output_path, - # dev_addr, - # open_in_browser, - # watch_index_theme, - # watch_index_template, - # watch_slides_theme, - # watch_slides_template, - # ) + serve( + config, + files, + output_path, + ) ################################################################################ diff --git a/src/mkslides/build.py b/src/mkslides/build.py index 17eb967..7310b23 100644 --- a/src/mkslides/build.py +++ b/src/mkslides/build.py @@ -1,5 +1,7 @@ import logging from pathlib import Path + +from omegaconf import DictConfig from mkslides.config import get_config from mkslides.markupgenerator import MarkupGenerator @@ -7,9 +9,8 @@ def build( - config_path: Path, input_path: Path, output_path: Path + config: DictConfig, input_path: Path, output_path: Path ) -> None: - config = get_config(config_path) 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 89c3409..1b01a33 100644 --- a/src/mkslides/config.py +++ b/src/mkslides/config.py @@ -4,7 +4,11 @@ import sys from typing import Any, Dict, Optional -from mkslides.constants import HIGHLIGHTJS_THEMES_LIST, REVEALJS_THEMES_LIST +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 from omegaconf import MISSING, DictConfig, OmegaConf @@ -98,15 +102,21 @@ def validate(config) -> None: 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 + if config_file: - config = OmegaConf.merge( - config, - OmegaConf.load(config_file), - ) - logger.info(f"Loaded config from {config_file}") + try: + loaded_config = OmegaConf.load(config_file) + config = OmegaConf.merge(config, loaded_config) + logger.info(f'Loaded config from "{config_file}"') + except Exception as e: + logger.error(f"Failed to load config from {config_file}: {e}") + raise assert OmegaConf.is_dict(config) - logger.debug(f"Used config: {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 33ac788..fb0ec33 100644 --- a/src/mkslides/constants.py +++ b/src/mkslides/constants.py @@ -1,4 +1,5 @@ import json +from pathlib import Path import re from importlib import metadata, resources @@ -15,7 +16,7 @@ ) VERSION = metadata.version("mkslides") -EXPECTED_CONFIG_LOCATION = "mkslides.yml" +DEFAULT_CONFIG_LOCATION = Path("mkslides.yml") DEFAULT_OUTPUT_DIR = "site" ASSETS_RESOURCE = resources.files("assets") diff --git a/src/mkslides/markupgenerator.py b/src/mkslides/markupgenerator.py index 58297da..b2834b1 100644 --- a/src/mkslides/markupgenerator.py +++ b/src/mkslides/markupgenerator.py @@ -193,7 +193,7 @@ def __process_markdown_file( def __process_markdown_directory(self, md_root_path: Path) -> None: md_root_path = md_root_path.resolve(strict=True) - logger.debug(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( diff --git a/src/mkslides/serve.py b/src/mkslides/serve.py index d4779bf..a0cd907 100644 --- a/src/mkslides/serve.py +++ b/src/mkslides/serve.py @@ -1,114 +1,74 @@ -import logging -from pathlib import Path -import shutil +# import logging +# from pathlib import Path +# import shutil -import livereload # type: ignore[import-untyped] -from livereload.handlers import LiveReloadHandler # type: ignore[import-untyped] +# import livereload # type: ignore[import-untyped] +# from livereload.handlers import LiveReloadHandler +# from omegaconf import DictConfig # type: ignore[import-untyped] -from mkslides.build import execute_build_command -from mkslides.config import get_config -from mkslides.markupgenerator import MarkupGenerator -from mkslides.utils import parse_ip_port +# from mkslides.build import execute_build_command +# from mkslides.config import get_config +# from mkslides.markupgenerator import MarkupGenerator +# from mkslides.utils import parse_ip_port -logger = logging.getLogger(__name__) +# logger = logging.getLogger(__name__) -LiveReloadHandler.DEFAULT_RELOAD_TIME = ( - 0 # https://github.com/lepture/python-livereload/pull/244 -) +# LiveReloadHandler.DEFAULT_RELOAD_TIME = ( +# 0 # https://github.com/lepture/python-livereload/pull/244 +# ) -class LiveReloadHandler: - def __init__( - self, # noqa: C901 - config_path: Path | None, - input_path: Path, - output_path: Path, - dev_addr: str, - open_in_browser: bool, - watch_index_theme: bool, - watch_index_template: bool, - watch_slides_theme: bool, - watch_slides_template: bool, - ): - self.config_path = config_path - self.input_path = input_path - self.output_path = output_path - self.dev_addr = dev_addr - self.open_in_browser = open_in_browser - self.watch_index_theme = watch_index_theme - self.watch_index_template = watch_index_template - self.watch_slides_theme = watch_slides_theme - self.watch_slides_template = watch_slides_template +# # def determine_watched_paths(self, config): +# # watched_paths = [ +# # self.input_path, +# # self.config_path, +# # config.index.theme if self.watch_index_theme else None, +# # config.index.template if self.watch_index_template else None, +# # config.slides.theme if self.watch_slides_theme else None, +# # config.slides.template if self.watch_slides_template else None, +# # ] - def determine_watched_paths(self, config): - watched_paths = [ - self.input_path, - self.config_path, - config.index.theme if self.watch_index_theme else None, - config.index.template if self.watch_index_template else None, - config.slides.theme if self.watch_slides_theme else None, - config.slides.template if self.watch_slides_template else None, - ] +# # for path in watched_paths: +# # if path: +# # resolved_path = Path(path).resolve(strict=True).absolute() +# # logger.debug(f'Watching: "{resolved_path}"') +# # server.watch(filepath=resolved_path.as_posix(), func=reload, delay=1) - for path in watched_paths: - if path: - resolved_path = Path(path).resolve(strict=True).absolute() - logger.debug(f'Watching: "{resolved_path}"') - server.watch(filepath=resolved_path.as_posix(), func=reload, delay=1) - def generate_slides(self): - self.config = get_config(config_path) - markup_generator = MarkupGenerator(config, output_path) - markup_generator.create_or_clear_output_directory() - markup_generator.process_markdown(input_path) - - def serve(): - try: - server = livereload.Server() - - # https://github.com/lepture/python-livereload/issues/232 - server._setup_logging = lambda: None # noqa: SLF001 - - watched_paths = [ - input_path, - config_path if config_path.exists() else None, - # config.index.theme if watch_index_theme else None, - # config.index.template if watch_index_template else None, - # config.slides.theme if watch_slides_theme else None, - # config.slides.template if watch_slides_template else None, - ] - - for path in watched_paths: - if path: - resolved_path = Path(path).resolve(strict=True).absolute() - logger.debug(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_path, - open_url_delay=0 if open_in_browser else None, - ) - - finally: - if output_path.exists(): - shutil.rmtree(output_path) - logger.debug(f'Removed "{output_path}"') - - -# def execute_serve_command( # noqa: C901 -# config_path: Path, -# input_path: Path, -# output_path: Path, -# dev_addr: str, -# open_in_browser: bool, -# watch_index_theme: bool, -# watch_index_template: bool, -# watch_slides_theme: bool, -# watch_slides_template: bool, +# def serve( +# config: DictConfig, input_path: Path, output_path: Path # ) -> None: +# try: +# server = livereload.Server() + +# # https://github.com/lepture/python-livereload/issues/232 +# server._setup_logging = lambda: None # noqa: SLF001 + +# watched_paths = [ +# input_path, +# config.serve.config_path if config.serve.exists() else None, +# # config.index.theme if config.serve.watch_index_theme else None, +# # config.index.template if config.serve.watch_index_template else None, +# # config.slides.theme if config.serve.watch_slides_theme else None, +# # config.slides.template if config.serve.watch_slides_template else None, +# ] + +# for path in watched_paths: +# if path: +# resolved_path = Path(path).resolve(strict=True).absolute() +# logger.debug(f'Watching: "{resolved_path}"') +# server.watch(filepath=resolved_path.as_posix(), func=reload, delay=1) + +# ip, port = parse_ip_port(config.serve.dev_addr) + +# server.serve( +# host=ip, +# port=port, +# root=output_path, +# open_url_delay=0 if config.serve.open_in_browser else None, +# ) + +# finally: +# if output_path.exists(): +# shutil.rmtree(output_path) +# logger.debug(f'Removed "{output_path}"') From cf85dadc6f6e99249190eee1c4cdcc4fd1a4c363 Mon Sep 17 00:00:00 2001 From: Martijn Saelens Date: Tue, 26 Nov 2024 00:15:45 +0100 Subject: [PATCH 6/9] Reworked config and testing --- .gitmodules | 4 +- README.md | 6 +- pyproject.toml | 13 +- src/mkslides/__main__.py | 56 ++---- {assets => src/mkslides/assets}/highlight.js | 0 {assets => src/mkslides/assets}/reveal.js | 0 .../assets}/templates/index.html.jinja | 0 .../assets}/templates/slideshow.html.jinja | 0 src/mkslides/build.py | 6 +- src/mkslides/config.py | 42 +++-- src/mkslides/constants.py | 35 ++-- src/mkslides/markupgenerator.py | 6 +- src/mkslides/serve.py | 165 ++++++++++-------- tests/conftest.py | 20 ++- tests/test_base.py | 22 +-- .../test_absolute_url_index_favicon_path.yml | 2 + .../test_absolute_url_index_theme_path.yml | 2 + ...st_absolute_url_slideshow_favicon_path.yml | 2 + ...test_absolute_url_slideshow_theme_path.yml | 3 + .../test_builtin_slideshow_theme_path.yml | 3 + tests/test_configs/test_index_title.yml | 2 + .../test_local_index_favicon_path.yml | 2 + .../test_local_index_theme_path.yml | 2 + .../test_local_slideshow_favicon_path.yml | 2 + .../test_local_slideshow_theme_path.yml | 3 + tests/test_configs/test_plugins.yml | 6 + .../test_revealjs_integer_options.yml | 3 + .../test_revealjs_markdown_data_options.yml | 5 + .../test_revealjs_string_options.yml | 2 + tests/test_emojis.py | 10 +- tests/test_index.py | 18 +- tests/test_markdown_data_options.py | 21 +-- tests/test_plugins.py | 32 +--- tests/test_revealjs_options.py | 47 ++--- tests/test_themes.py | 145 ++++----------- tests/utils.py | 41 ++++- 36 files changed, 342 insertions(+), 386 deletions(-) rename {assets => src/mkslides/assets}/highlight.js (100%) rename {assets => src/mkslides/assets}/reveal.js (100%) rename {assets => src/mkslides/assets}/templates/index.html.jinja (100%) rename {assets => src/mkslides/assets}/templates/slideshow.html.jinja (100%) create mode 100644 tests/test_configs/test_absolute_url_index_favicon_path.yml create mode 100644 tests/test_configs/test_absolute_url_index_theme_path.yml create mode 100644 tests/test_configs/test_absolute_url_slideshow_favicon_path.yml create mode 100644 tests/test_configs/test_absolute_url_slideshow_theme_path.yml create mode 100644 tests/test_configs/test_builtin_slideshow_theme_path.yml create mode 100644 tests/test_configs/test_index_title.yml create mode 100644 tests/test_configs/test_local_index_favicon_path.yml create mode 100644 tests/test_configs/test_local_index_theme_path.yml create mode 100644 tests/test_configs/test_local_slideshow_favicon_path.yml create mode 100644 tests/test_configs/test_local_slideshow_theme_path.yml create mode 100644 tests/test_configs/test_plugins.yml create mode 100644 tests/test_configs/test_revealjs_integer_options.yml create mode 100644 tests/test_configs/test_revealjs_markdown_data_options.yml create mode 100644 tests/test_configs/test_revealjs_string_options.yml 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 8f0a334..57d5f6c 100644 --- a/README.md +++ b/README.md @@ -109,11 +109,11 @@ revealjs: # Plugins or additional CSS/JavaScript files for the slides. These are given as # a list. plugins: - # Name of the plugin (optional): plugin id string + # Name of the plugin (optional, see plugin README): plugin id string # (see https://revealjs.com/creating-plugins/#registering-a-plugin) - name: RevealMermaid - # JavaScript file of the plugin: file path or public url to JavaScript - # file + # 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: diff --git a/pyproject.toml b/pyproject.toml index 4e032d4..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" @@ -62,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 @@ -74,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 766ffbe..6513881 100644 --- a/src/mkslides/__main__.py +++ b/src/mkslides/__main__.py @@ -1,11 +1,14 @@ import logging -import sys import tempfile from pathlib import Path import click +from omegaconf import OmegaConf from rich.logging import RichHandler +from mkslides.serve import serve +from mkslides.utils import parse_ip_port + from .build import build from .config import get_config from .constants import ( @@ -53,9 +56,8 @@ help="Enable verbose output", is_flag=True, ) -def cli(verbose) -> None: +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") @@ -70,7 +72,7 @@ def cli(verbose) -> None: @click.option( "-d", "--site-dir", - type=click.Path(exists=True, resolve_path=True, path_type=Path), + type=click.Path(path_type=Path), help="The directory to output the result of the slides build.", metavar="PATH", default=DEFAULT_OUTPUT_DIR, @@ -109,35 +111,11 @@ def build_command(files: Path, config_file: Path | None, 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, -) -def serve_command( # noqa: C901 +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, ) -> None: """ Run the builtin development server. @@ -147,25 +125,21 @@ def serve_command( # noqa: C901 logger.debug("Command: serve") config = get_config(config_file) - config.merge_with( + output_path = Path(tempfile.mkdtemp(prefix="mkslides_")).resolve(strict=False) + dev_ip, dev_port = parse_ip_port(dev_addr) + serve_config = OmegaConf.structured( { - "serve": { - "config_file": config_file, - "dev_addr": dev_addr, - "open_in_browser": open_in_browser, - "watch_index_theme": watch_index_theme, - "watch_index_template": watch_index_template, - "watch_slides_theme": watch_slides_theme, - "watch_slides_template": watch_slides_template, - } - } + "dev_ip": dev_ip, + "dev_port": dev_port, + "open_in_browser": open_in_browser, + }, ) - output_path = Path(tempfile.mkdtemp(prefix="mkslides_")).resolve(strict=False) serve( config, files, output_path, + serve_config, ) 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 index 7310b23..6c0d124 100644 --- a/src/mkslides/build.py +++ b/src/mkslides/build.py @@ -2,15 +2,13 @@ from pathlib import Path from omegaconf import DictConfig -from mkslides.config import get_config + from mkslides.markupgenerator import MarkupGenerator logger = logging.getLogger(__name__) -def build( - config: DictConfig, input_path: Path, output_path: Path -) -> None: +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 1b01a33..80d125d 100644 --- a/src/mkslides/config.py +++ b/src/mkslides/config.py @@ -1,9 +1,10 @@ -from dataclasses import dataclass, field import logging +from dataclasses import dataclass, field from pathlib import Path -import sys from typing import Any, Dict, Optional +from omegaconf import MISSING, DictConfig, OmegaConf + from mkslides.constants import ( DEFAULT_CONFIG_LOCATION, HIGHLIGHTJS_THEMES_LIST, @@ -11,7 +12,6 @@ ) from mkslides.urltype import URLType from mkslides.utils import get_url_type -from omegaconf import MISSING, DictConfig, OmegaConf logger = logging.getLogger(__name__) @@ -39,7 +39,13 @@ class Slides: @dataclass class Plugin: name: Optional[str] = None - extra_javascript: Optional[str] = MISSING + extra_javascript: list[str] = MISSING + + +# For internal use only +@dataclass +class Internal: + config_path: Optional[Path] = MISSING @dataclass @@ -50,12 +56,13 @@ class Config: 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) -> None: +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) @@ -68,7 +75,7 @@ def validate(config) -> None: if ( config.index.theme and get_url_type(config.index.theme) == URLType.RELATIVE - and not config.index.theme in REVEALJS_THEMES_LIST + and config.index.theme not in REVEALJS_THEMES_LIST ): Path(config.index.theme).resolve(strict=True) @@ -81,7 +88,7 @@ def validate(config) -> None: if ( config.slides.highlight_theme and get_url_type(config.slides.highlight_theme) == URLType.RELATIVE - and not config.slides.highlight_theme in HIGHLIGHTJS_THEMES_LIST + and config.slides.highlight_theme not in HIGHLIGHTJS_THEMES_LIST ): Path(config.slides.highlight_theme).resolve(strict=True) @@ -91,27 +98,28 @@ def validate(config) -> None: ): Path(config.slides.template).resolve(strict=True) - if config.slides.theme: - if get_url_type(config.slides.theme) == URLType.RELATIVE: - if config.slides.theme in REVEALJS_THEMES_LIST: - pass - else: - Path(config.slides.theme).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 + 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 as e: - logger.error(f"Failed to load config from {config_file}: {e}") + except Exception: + logger.exception(f"Failed to load config from {config_file}") raise assert OmegaConf.is_dict(config) diff --git a/src/mkslides/constants.py b/src/mkslides/constants.py index fb0ec33..2289c61 100644 --- a/src/mkslides/constants.py +++ b/src/mkslides/constants.py @@ -1,10 +1,27 @@ import json -from pathlib import Path 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 @@ -15,28 +32,22 @@ re.VERBOSE, ) -VERSION = metadata.version("mkslides") +VERSION = metadata.version(__package__) DEFAULT_CONFIG_LOCATION = Path("mkslides.yml") DEFAULT_OUTPUT_DIR = "site" -ASSETS_RESOURCE = resources.files("assets") +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 = [ - theme.stem for theme in REVEALJS_THEMES_RESOURCE.iterdir() - if theme.is_file() and theme.suffix == ".css" -] +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 = [ - theme.stem for theme in HIGHLIGHTJS_THEMES_RESOURCE.iterdir() - if theme.is_file() and theme.suffix == ".css" -] +HIGHLIGHTJS_THEMES_LIST = gather_themes(HIGHLIGHTJS_THEMES_RESOURCE) HIGHLIGHTJS_THEMES_VERSION = None with HIGHLIGHTJS_RESOURCE.joinpath("build", "package.json").open( encoding="utf-8-sig", @@ -44,7 +55,7 @@ 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") diff --git a/src/mkslides/markupgenerator.py b/src/mkslides/markupgenerator.py index b2834b1..746afc1 100644 --- a/src/mkslides/markupgenerator.py +++ b/src/mkslides/markupgenerator.py @@ -6,17 +6,16 @@ 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 mkslides.utils import get_url_type from omegaconf import DictConfig -from .config import Config +from mkslides.utils import get_url_type + from .constants import ( DEFAULT_INDEX_TEMPLATE, DEFAULT_SLIDESHOW_TEMPLATE, @@ -37,7 +36,6 @@ def __init__( config: DictConfig, output_directory_path: Path, ) -> None: - self.config = config self.output_directory_path = output_directory_path.resolve(strict=False) diff --git a/src/mkslides/serve.py b/src/mkslides/serve.py index a0cd907..ddd40a6 100644 --- a/src/mkslides/serve.py +++ b/src/mkslides/serve.py @@ -1,74 +1,91 @@ -# import logging -# from pathlib import Path -# import shutil - -# import livereload # type: ignore[import-untyped] -# from livereload.handlers import LiveReloadHandler -# from omegaconf import DictConfig # type: ignore[import-untyped] - -# from mkslides.build import execute_build_command -# from mkslides.config import get_config -# from mkslides.markupgenerator import MarkupGenerator -# from mkslides.utils import parse_ip_port - -# logger = logging.getLogger(__name__) - -# LiveReloadHandler.DEFAULT_RELOAD_TIME = ( -# 0 # https://github.com/lepture/python-livereload/pull/244 -# ) - - -# # def determine_watched_paths(self, config): -# # watched_paths = [ -# # self.input_path, -# # self.config_path, -# # config.index.theme if self.watch_index_theme else None, -# # config.index.template if self.watch_index_template else None, -# # config.slides.theme if self.watch_slides_theme else None, -# # config.slides.template if self.watch_slides_template else None, -# # ] - -# # for path in watched_paths: -# # if path: -# # resolved_path = Path(path).resolve(strict=True).absolute() -# # logger.debug(f'Watching: "{resolved_path}"') -# # server.watch(filepath=resolved_path.as_posix(), func=reload, delay=1) - - -# def serve( -# config: DictConfig, input_path: Path, output_path: Path -# ) -> None: -# try: -# server = livereload.Server() - -# # https://github.com/lepture/python-livereload/issues/232 -# server._setup_logging = lambda: None # noqa: SLF001 - -# watched_paths = [ -# input_path, -# config.serve.config_path if config.serve.exists() else None, -# # config.index.theme if config.serve.watch_index_theme else None, -# # config.index.template if config.serve.watch_index_template else None, -# # config.slides.theme if config.serve.watch_slides_theme else None, -# # config.slides.template if config.serve.watch_slides_template else None, -# ] - -# for path in watched_paths: -# if path: -# resolved_path = Path(path).resolve(strict=True).absolute() -# logger.debug(f'Watching: "{resolved_path}"') -# server.watch(filepath=resolved_path.as_posix(), func=reload, delay=1) - -# ip, port = parse_ip_port(config.serve.dev_addr) - -# server.serve( -# host=ip, -# port=port, -# root=output_path, -# open_url_delay=0 if config.serve.open_in_browser else None, -# ) - -# finally: -# if output_path.exists(): -# shutil.rmtree(output_path) -# logger.debug(f'Removed "{output_path}"') +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/tests/conftest.py b/tests/conftest.py index a3c710e..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_or_clear_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: From 554e671939b2d303a44f9d96ea9acd0900fe67bf Mon Sep 17 00:00:00 2001 From: Martijn Saelens Date: Tue, 26 Nov 2024 15:27:32 +0100 Subject: [PATCH 7/9] Added frontmatter support --- README.md | 13 ++++++++ src/mkslides/config.py | 1 + src/mkslides/markupgenerator.py | 54 +++++++++++++++++++++------------ 3 files changed, 48 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 57d5f6c..f103c0c 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,19 @@ plugins: - https://cdn.jsdelivr.net/npm/reveal-plantuml/dist/reveal-plantuml.min.js ``` +Default config: + +```yml +index: + title: Index +slides: + theme: black + highlight_theme: monokai +revealjs: + history: true + slideNumber: c/t +``` + ## Contributing You can run the tests with `poetry` and `pytest`: diff --git a/src/mkslides/config.py b/src/mkslides/config.py index 80d125d..0c9cdf3 100644 --- a/src/mkslides/config.py +++ b/src/mkslides/config.py @@ -15,6 +15,7 @@ logger = logging.getLogger(__name__) +FRONTMATTER_ALLOWED_KEYS = ["slides", "revealjs", "plugins"] @dataclass class Index: diff --git a/src/mkslides/markupgenerator.py b/src/mkslides/markupgenerator.py index 746afc1..03cefbb 100644 --- a/src/mkslides/markupgenerator.py +++ b/src/mkslides/markupgenerator.py @@ -1,3 +1,4 @@ +from copy import deepcopy import datetime import logging import shutil @@ -12,8 +13,9 @@ from bs4 import BeautifulSoup, Comment from emoji import emojize from natsort import natsorted -from omegaconf import DictConfig +from omegaconf import DictConfig, OmegaConf +from mkslides.config import FRONTMATTER_ALLOWED_KEYS, Config from mkslides.utils import get_url_type from .constants import ( @@ -33,10 +35,10 @@ class MarkupGenerator: def __init__( self, - config: DictConfig, + global_config: DictConfig, output_directory_path: Path, ) -> None: - self.config = config + self.global_config = global_config self.output_directory_path = output_directory_path.resolve(strict=False) logger.info( @@ -104,6 +106,18 @@ def __process_markdown_file( 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(f"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 @@ -118,12 +132,12 @@ def __process_markdown_file( walk_up=True, ) - revealjs_config = self.config.revealjs + revealjs_config = slide_config.revealjs # Copy the theme CSS relative_theme_path = None - if theme := self.config.slides.theme: + if theme := slide_config.slides.theme: relative_theme_path = self.__copy_theme( output_markup_path, theme, @@ -133,7 +147,7 @@ def __process_markdown_file( # Copy the highlight CSS relative_highlight_theme_path = None - if theme := self.config.slides.highlight_theme: + if theme := slide_config.slides.highlight_theme: relative_highlight_theme_path = self.__copy_theme( output_markup_path, theme, @@ -143,18 +157,18 @@ def __process_markdown_file( # Copy the favicon relative_favicon_path = None - if favicon := self.config.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.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.slides.template: + if template_config := slide_config.slides.template: slideshow_template = LOCAL_JINJA2_ENVIRONMENT.get_template(template_config) else: slideshow_template = DEFAULT_SLIDESHOW_TEMPLATE @@ -163,10 +177,10 @@ def __process_markdown_file( markdown_data_options = { key: value for key, value in { - "data-separator": self.config.slides.separator, - "data-separator-vertical": self.config.slides.separator_vertical, - "data-separator-notes": self.config.slides.separator_notes, - "data-charset": self.config.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 } @@ -187,21 +201,21 @@ 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.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, ), @@ -217,25 +231,25 @@ def __process_markdown_directory(self, md_root_path: Path) -> None: # Copy the theme relative_theme_path = None - if theme := self.config.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.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.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.index.title, + title=self.global_config.index.title, theme=relative_theme_path, slideshows=slideshows, build_datetime=datetime.datetime.now(tz=datetime.timezone.utc), From 0ce264b176464e7c8a53f1c41f4f596e8909fd71 Mon Sep 17 00:00:00 2001 From: Martijn Saelens Date: Tue, 26 Nov 2024 15:40:43 +0100 Subject: [PATCH 8/9] Added frontmatter test and documentation --- README.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/README.md b/README.md index f103c0c..ecd3d9a 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,37 @@ revealjs: 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 You can run the tests with `poetry` and `pytest`: From e1b8e25896929bb94eaf9596a513e73f8394af10 Mon Sep 17 00:00:00 2001 From: Martijn Saelens Date: Tue, 26 Nov 2024 15:46:43 +0100 Subject: [PATCH 9/9] Fixed formatting and linting --- src/mkslides/config.py | 1 + src/mkslides/markupgenerator.py | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/mkslides/config.py b/src/mkslides/config.py index 0c9cdf3..e45f3e2 100644 --- a/src/mkslides/config.py +++ b/src/mkslides/config.py @@ -17,6 +17,7 @@ FRONTMATTER_ALLOWED_KEYS = ["slides", "revealjs", "plugins"] + @dataclass class Index: favicon: Optional[str] = None diff --git a/src/mkslides/markupgenerator.py b/src/mkslides/markupgenerator.py index 03cefbb..d75bc76 100644 --- a/src/mkslides/markupgenerator.py +++ b/src/mkslides/markupgenerator.py @@ -1,8 +1,8 @@ -from copy import deepcopy import datetime import logging import shutil import time +from copy import deepcopy from importlib import resources from importlib.resources.abc import Traversable from pathlib import Path @@ -15,7 +15,7 @@ from natsort import natsorted from omegaconf import DictConfig, OmegaConf -from mkslides.config import FRONTMATTER_ALLOWED_KEYS, Config +from mkslides.config import FRONTMATTER_ALLOWED_KEYS from mkslides.utils import get_url_type from .constants import ( @@ -113,7 +113,7 @@ def __process_markdown_file( for key in FRONTMATTER_ALLOWED_KEYS: if key in metadata: OmegaConf.update(slide_config, key, metadata[key]) - logger.debug(f"Detected frontmatter, used config:") + logger.debug("Detected frontmatter, used config:") logger.debug(OmegaConf.to_yaml(slide_config, resolve=True)) else: slide_config = self.global_config @@ -222,7 +222,7 @@ def __process_markdown_directory(self, md_root_path: Path) -> None: }, ) - slideshows = natsorted(slideshows, key=lambda x: x["location"]) + slideshows = natsorted(slideshows, key=lambda x: str(x["location"])) logger.debug("Generating index")