513 lines
20 KiB
Python
513 lines
20 KiB
Python
#! python3 # noqa: E265
|
|
|
|
# ############################################################################
|
|
# ########## Libraries #############
|
|
# ##################################
|
|
|
|
# standard library
|
|
import json
|
|
from copy import deepcopy
|
|
from dataclasses import asdict
|
|
from datetime import datetime
|
|
from email.utils import format_datetime, formatdate
|
|
from pathlib import Path
|
|
from re import compile as re_compile
|
|
from shutil import copyfile
|
|
from typing import Literal
|
|
|
|
# 3rd party
|
|
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
|
from mkdocs.config import config_options
|
|
from mkdocs.config.defaults import MkDocsConfig
|
|
from mkdocs.plugins import BasePlugin, event_priority, get_plugin_logger
|
|
from mkdocs.structure.files import Files
|
|
from mkdocs.structure.pages import Page
|
|
from mkdocs.utils import get_build_timestamp
|
|
|
|
# package modules
|
|
from mkdocs_rss_plugin.__about__ import __title__, __version__
|
|
from mkdocs_rss_plugin.config import RssPluginConfig
|
|
from mkdocs_rss_plugin.constants import (
|
|
DEFAULT_TEMPLATE_FILENAME,
|
|
DEFAULT_TEMPLATE_FOLDER,
|
|
MKDOCS_LOGGER_NAME,
|
|
)
|
|
from mkdocs_rss_plugin.integrations.theme_material_blog_plugin import (
|
|
IntegrationMaterialBlog,
|
|
)
|
|
from mkdocs_rss_plugin.integrations.theme_material_social_plugin import (
|
|
IntegrationMaterialSocialCards,
|
|
)
|
|
from mkdocs_rss_plugin.models import MkdocsPageSubset, PageInformation, RssFeedBase
|
|
from mkdocs_rss_plugin.util import Util
|
|
|
|
# ############################################################################
|
|
# ########## Globals #############
|
|
# ################################
|
|
|
|
|
|
logger = get_plugin_logger(MKDOCS_LOGGER_NAME)
|
|
|
|
|
|
# ############################################################################
|
|
# ########## Classes ###############
|
|
# ##################################
|
|
|
|
|
|
class GitRssPlugin(BasePlugin[RssPluginConfig]):
|
|
"""Main class for MkDocs plugin."""
|
|
|
|
# allow to set the plugin multiple times in the same mkdocs config
|
|
supports_multiple_instances = True
|
|
|
|
def __init__(self, *args, **kwargs) -> None:
|
|
"""Instantiation."""
|
|
# pages storage
|
|
super().__init__(*args, **kwargs)
|
|
|
|
self.cmd_is_serve: bool = False
|
|
|
|
def on_startup(
|
|
self, *, command: Literal["build", "gh-deploy", "serve"], dirty: bool
|
|
) -> None:
|
|
"""The `startup` event runs once at the very beginning of an `mkdocs` invocation.
|
|
Note that for initializing variables, the __init__ method is still preferred.
|
|
For initializing per-build variables (and whenever in doubt), use the
|
|
on_config event.
|
|
|
|
See: https://www.mkdocs.org/user-guide/plugins/#on_startup
|
|
|
|
Args:
|
|
command: the command that MkDocs was invoked with, e.g. "serve" for `mkdocs serve`.
|
|
dirty: whether `--dirty` flag was passed.
|
|
"""
|
|
# flag used command to disable some actions if serve is used
|
|
self.cmd_is_serve = command == "serve"
|
|
|
|
self.pages_to_filter: list[PageInformation] = []
|
|
# prepare output feeds
|
|
self.feed_created: RssFeedBase = RssFeedBase()
|
|
self.feed_updated: RssFeedBase = RssFeedBase()
|
|
|
|
def on_config(self, config: MkDocsConfig) -> MkDocsConfig:
|
|
"""The config event is the first event called on build and
|
|
is run immediately after the user configuration is loaded and validated.
|
|
Any alterations to the config should be made here.
|
|
|
|
See: https://www.mkdocs.org/user-guide/plugins/#on_config
|
|
|
|
Args:
|
|
config (config_options.Config): global configuration object
|
|
|
|
Raises:
|
|
FileExistsError: if the template for the RSS feed is not found
|
|
PluginError: if the 'date_from_meta.default_time' format does not comply
|
|
|
|
Returns:
|
|
MkDocsConfig: global configuration object
|
|
"""
|
|
# Skip if disabled
|
|
if not self.config.enabled:
|
|
return config
|
|
|
|
# Fail if any export option is enabled
|
|
if not any([self.config.json_feed_enabled, self.config.rss_feed_enabled]):
|
|
logger.error(
|
|
"At least one export option has to be enabled. Plugin is disabled."
|
|
)
|
|
self.config.enabled = False
|
|
return config
|
|
|
|
# cache dir
|
|
self.cache_dir = Path(self.config.cache_dir)
|
|
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
logger.debug(f"Caching HTTP requests to: {self.cache_dir.resolve()}")
|
|
|
|
# integrations - check if theme is Material and if blog are enabled
|
|
self.integration_material_blog = IntegrationMaterialBlog(
|
|
mkdocs_config=config,
|
|
switch_force=self.config.use_material_blog,
|
|
)
|
|
|
|
# integrations - check if theme is Material and if social cards are enabled
|
|
self.integration_material_social_cards = IntegrationMaterialSocialCards(
|
|
mkdocs_config=config,
|
|
switch_force=self.config.use_material_social_cards,
|
|
)
|
|
|
|
# instantiate plugin tooling
|
|
self.util = Util(
|
|
cache_dir=self.cache_dir,
|
|
use_git=self.config.use_git,
|
|
integration_material_blog=self.integration_material_blog,
|
|
integration_material_social_cards=self.integration_material_social_cards,
|
|
mkdocs_command_is_on_serve=self.cmd_is_serve,
|
|
)
|
|
|
|
# check template dirs
|
|
if not Path(DEFAULT_TEMPLATE_FILENAME).is_file():
|
|
raise FileExistsError(DEFAULT_TEMPLATE_FILENAME)
|
|
self.tpl_file = Path(DEFAULT_TEMPLATE_FILENAME)
|
|
self.tpl_folder = DEFAULT_TEMPLATE_FOLDER
|
|
|
|
# start a feed dictionary using global config vars
|
|
base_feed = RssFeedBase(
|
|
author=config.site_author or None,
|
|
buildDate=formatdate(get_build_timestamp()),
|
|
copyright=config.copyright,
|
|
description=(
|
|
self.config.feed_description
|
|
if self.config.feed_description
|
|
else config.site_description
|
|
),
|
|
entries=[],
|
|
generator=f"{__title__} - v{__version__}",
|
|
html_url=self.util.get_site_url(mkdocs_config=config),
|
|
language=self.util.guess_locale(mkdocs_config=config),
|
|
pubDate=formatdate(get_build_timestamp()),
|
|
repo_url=config.repo_url,
|
|
title=(
|
|
self.config.feed_title if self.config.feed_title else config.site_name
|
|
),
|
|
ttl=self.config.feed_ttl,
|
|
)
|
|
|
|
# feed image
|
|
if self.config.image:
|
|
base_feed.logo_url = self.config.image
|
|
|
|
# feed stylesheet (XSL)
|
|
if self.config.stylesheet:
|
|
if self.config.stylesheet == "auto":
|
|
base_feed.stylesheet = "rss.xsl"
|
|
logger.debug(
|
|
f"Shipped stylesheet will be referenced in RSS feeds: {self.config.stylesheet}"
|
|
)
|
|
else:
|
|
|
|
base_feed.stylesheet = self.config.stylesheet
|
|
logger.debug(
|
|
f"Stylesheet will be referenced in RSS feeds: {self.config.stylesheet}"
|
|
)
|
|
else:
|
|
logger.debug("No stylesheet will be referenced in RSS feeds.")
|
|
|
|
# pattern to match pages included in output
|
|
self.match_path_pattern = re_compile(self.config.match_path)
|
|
|
|
# date handling
|
|
if (
|
|
self.config.date_from_meta.as_creation == "git"
|
|
and self.config.date_from_meta.as_update == "git"
|
|
):
|
|
logger.debug("Dates will be retrieved from git log.")
|
|
elif any(
|
|
[
|
|
isinstance(self.config.date_from_meta.as_creation, bool),
|
|
isinstance(self.config.date_from_meta.as_update, bool),
|
|
]
|
|
):
|
|
deprecation_msg = (
|
|
"Since version 1.13, using a boolean for "
|
|
"'date_from_meta.as_creation' and 'date_from_meta.as_update' is "
|
|
"deprecated. Please update your "
|
|
"`rss` plugin settings in your Mkdocs configuration "
|
|
f"({config.config_file_path}) by using a str or removing the value if "
|
|
"you were using `False`., "
|
|
)
|
|
logger.warning(DeprecationWarning(deprecation_msg))
|
|
self.config.date_from_meta.as_creation = (
|
|
self.config.date_from_meta.as_update
|
|
) = "git"
|
|
|
|
# check if default time complies with expected format
|
|
try:
|
|
self.config.date_from_meta.default_time = datetime.strptime(
|
|
self.config.date_from_meta.default_time, "%H:%M"
|
|
)
|
|
except (TypeError, ValueError) as err:
|
|
logger.warning(
|
|
"Config error: `date_from_meta.default_time` value "
|
|
f"'{self.config.date_from_meta.default_time}' format doesn't match the "
|
|
f"expected format %H:%M. Fallback to the default value. Trace: {err}"
|
|
)
|
|
self.config.date_from_meta.default_time = datetime.strptime(
|
|
"00:00", "%H:%M"
|
|
)
|
|
|
|
if self.config.use_git:
|
|
logger.debug(
|
|
"Dates will be retrieved FIRSTLY from page meta (yaml "
|
|
"frontmatter). The git log will be used as fallback."
|
|
)
|
|
else:
|
|
logger.debug(
|
|
"Dates will be retrieved ONLY from page meta (yaml "
|
|
"frontmatter). The build date will be used as fallback, without any "
|
|
"call to Git."
|
|
)
|
|
|
|
# create 2 final dicts
|
|
self.feed_created = deepcopy(base_feed)
|
|
self.feed_updated = deepcopy(base_feed)
|
|
|
|
# final feed url
|
|
if base_feed.html_url:
|
|
# concatenate both URLs
|
|
self.feed_created.rss_url = (
|
|
base_feed.html_url + self.config.feeds_filenames.rss_created
|
|
)
|
|
self.feed_updated.rss_url = (
|
|
base_feed.html_url + self.config.feeds_filenames.rss_updated
|
|
)
|
|
self.feed_created.json_url = (
|
|
base_feed.html_url + self.config.feeds_filenames.json_created
|
|
)
|
|
self.feed_updated.json_url = (
|
|
base_feed.html_url + self.config.feeds_filenames.json_updated
|
|
)
|
|
else:
|
|
logger.error(
|
|
"The variable `site_url` is not set in the MkDocs "
|
|
"configuration file whereas a URL is mandatory to publish. "
|
|
"See: https://validator.w3.org/feed/docs/rss2.html#requiredChannelElements"
|
|
)
|
|
self.feed_created.rss_url = self.feed_updated.json_url = (
|
|
self.feed_updated.rss_url
|
|
) = self.feed_updated.json_url = None
|
|
|
|
# ending event
|
|
return config
|
|
|
|
@event_priority(priority=-75)
|
|
def on_page_content(
|
|
self, html: str, page: Page, config: MkDocsConfig, files: Files
|
|
) -> str | None:
|
|
"""The page_content event is called after the Markdown text is rendered to HTML
|
|
(but before being passed to a template) and can be used to alter the HTML
|
|
body of the page.
|
|
|
|
See: https://www.mkdocs.org/user-guide/plugins/#on_page_content
|
|
|
|
Args:
|
|
html (str): HTML rendered from Markdown source as string
|
|
page (Page): `mkdocs.structure.pages.Page` instance
|
|
config (MkDocsConfig): global configuration object
|
|
files (Files): global files collection
|
|
|
|
Returns:
|
|
Optional[str]: HTML rendered from Markdown source as string
|
|
"""
|
|
# Skip if disabled
|
|
if not self.config.enabled:
|
|
return
|
|
|
|
# skip pages that don't match the config var match_path
|
|
if not self.match_path_pattern.match(page.file.src_uri):
|
|
return
|
|
|
|
# skip pages with draft=true
|
|
if page.meta.get("draft", False) is True:
|
|
logger.debug(f"Page {page.title} ignored because it's a draft")
|
|
return
|
|
|
|
# retrieve dates from git log
|
|
page_dates = self.util.get_file_dates(
|
|
in_page=page,
|
|
source_date_creation=self.config.date_from_meta.as_creation,
|
|
source_date_update=self.config.date_from_meta.as_update,
|
|
meta_datetime_format=self.config.date_from_meta.datetime_format,
|
|
meta_default_timezone=self.config.date_from_meta.default_timezone,
|
|
meta_default_time=self.config.date_from_meta.default_time,
|
|
)
|
|
|
|
# handle custom URL parameters
|
|
if self.config.url_parameters:
|
|
page_url_full = self.util.build_url(
|
|
base_url=page.canonical_url,
|
|
path="",
|
|
args_dict=self.config.url_parameters,
|
|
)
|
|
else:
|
|
page_url_full = page.canonical_url
|
|
|
|
# handle URL comment path
|
|
if self.config.comments_path:
|
|
page_url_comments = self.util.build_url(
|
|
base_url=page.canonical_url,
|
|
path=self.config.comments_path,
|
|
)
|
|
else:
|
|
page_url_comments = None
|
|
|
|
# append to list to be filtered later
|
|
self.pages_to_filter.append(
|
|
PageInformation(
|
|
abs_path=Path(page.file.abs_src_path),
|
|
authors=self.util.get_authors_from_meta(in_page=page),
|
|
categories=self.util.get_categories_from_meta(
|
|
in_page=page, categories_labels=self.config.categories
|
|
),
|
|
comments_url=page_url_comments,
|
|
created=page_dates[0],
|
|
description=self.util.get_description_or_abstract(
|
|
in_page=page,
|
|
chars_count=self.config.abstract_chars_count,
|
|
abstract_delimiter=self.config.abstract_delimiter,
|
|
),
|
|
guid=page.canonical_url,
|
|
link=page_url_full,
|
|
title=page.title,
|
|
updated=page_dates[1],
|
|
# for later fetch
|
|
_mkdocs_page_ref=MkdocsPageSubset.from_page(page),
|
|
)
|
|
)
|
|
|
|
def on_post_build(self, config: config_options.Config) -> None:
|
|
"""The post_build event does not alter any variables. Use this event to call
|
|
post-build scripts.
|
|
|
|
See:
|
|
<https://www.mkdocs.org/user-guide/plugins/#on_post_build>
|
|
|
|
Args:
|
|
config (config_options.Config): global configuration object
|
|
"""
|
|
# Skip if disabled
|
|
if not self.config.enabled:
|
|
return
|
|
|
|
# pretty print or not
|
|
pretty_print = self.config.pretty_print
|
|
|
|
# output filepaths
|
|
out_feed_created = Path(config.site_dir).joinpath(
|
|
self.config.feeds_filenames.rss_created
|
|
)
|
|
out_feed_updated = Path(config.site_dir).joinpath(
|
|
self.config.feeds_filenames.rss_updated
|
|
)
|
|
out_json_created = Path(config.site_dir).joinpath(
|
|
self.config.feeds_filenames.json_created
|
|
)
|
|
out_json_updated = Path(config.site_dir).joinpath(
|
|
self.config.feeds_filenames.json_updated
|
|
)
|
|
|
|
# stylesheet for RSS feed
|
|
if self.config.stylesheet == "auto":
|
|
xsl_source = self.tpl_folder.joinpath("default.xsl")
|
|
xsl_dest = Path(config.site_dir).joinpath("rss.xsl")
|
|
copyfile(xsl_source, xsl_dest)
|
|
|
|
# created items
|
|
self.feed_created.entries.extend(
|
|
self.util.filter_pages(
|
|
pages=self.pages_to_filter,
|
|
filter_attribute="created",
|
|
length=self.config.length,
|
|
)
|
|
)
|
|
|
|
# updated items
|
|
self.feed_updated.entries.extend(
|
|
self.util.filter_pages(
|
|
pages=self.pages_to_filter,
|
|
filter_attribute="updated",
|
|
length=self.config.length,
|
|
)
|
|
)
|
|
|
|
# load RSS items images (enclosures)
|
|
logger.debug(
|
|
f"Loading images for {len(self.feed_created.entries)} pages by creation "
|
|
f"and {len(self.feed_updated.entries)} pages by update"
|
|
)
|
|
processed_refs = set()
|
|
self.util.load_images_for_pages(
|
|
self.feed_created.entries, config.site_url, processed_refs
|
|
)
|
|
self.util.load_images_for_pages(
|
|
self.feed_updated.entries, config.site_url, processed_refs
|
|
)
|
|
|
|
# RSS
|
|
if self.config.rss_feed_enabled:
|
|
# Jinja environment depending on the pretty print option
|
|
if pretty_print:
|
|
# load Jinja environment and template
|
|
env = Environment(
|
|
autoescape=select_autoescape(["html", "xml"]),
|
|
loader=FileSystemLoader(self.tpl_folder),
|
|
)
|
|
else:
|
|
# load Jinja environment and template
|
|
env = Environment(
|
|
autoescape=select_autoescape(["html", "xml"]),
|
|
loader=FileSystemLoader(self.tpl_folder),
|
|
lstrip_blocks=True,
|
|
trim_blocks=True,
|
|
)
|
|
template = env.get_template(self.tpl_file.name)
|
|
|
|
# -- Feed sorted by creation date
|
|
logger.debug("Fill creation dates and dump created feed into RSS template.")
|
|
# set pub date as created
|
|
for page in self.feed_created.entries:
|
|
page.pub_date = format_datetime(dt=page.created)
|
|
|
|
# write file
|
|
with out_feed_created.open(mode="w", encoding="UTF8") as fifeed_created:
|
|
if pretty_print:
|
|
fifeed_created.write(template.render(feed=self.feed_created))
|
|
else:
|
|
prev_char = ""
|
|
for char in template.render(feed=asdict(self.feed_created)):
|
|
if char == "\n":
|
|
# convert new lines to spaces to preserve sentence structure
|
|
char = " "
|
|
if char == " " and prev_char == " ":
|
|
prev_char = char
|
|
continue
|
|
prev_char = char
|
|
fifeed_created.write(char)
|
|
|
|
# -- Feed sorted by last update date
|
|
logger.debug("Fill update dates and dump udpated feed into RSS template.")
|
|
# set pub date as updated
|
|
for page in self.feed_updated.entries:
|
|
page.pub_date = format_datetime(dt=page.updated)
|
|
|
|
# write file
|
|
with out_feed_updated.open(mode="w", encoding="UTF8") as fifeed_updated:
|
|
if pretty_print:
|
|
fifeed_updated.write(template.render(feed=self.feed_updated))
|
|
else:
|
|
prev_char = ""
|
|
for char in template.render(feed=asdict(self.feed_updated)):
|
|
if char == "\n":
|
|
# convert new lines to spaces to preserve sentence structure
|
|
char = " "
|
|
if char == " " and prev_char == " ":
|
|
prev_char = char
|
|
continue
|
|
prev_char = char
|
|
fifeed_updated.write(char)
|
|
|
|
# JSON FEED
|
|
if self.config.json_feed_enabled:
|
|
with out_json_created.open(mode="w", encoding="UTF8") as fp:
|
|
json.dump(
|
|
self.util.feed_to_json(self.feed_created),
|
|
fp,
|
|
indent=4 if self.config.pretty_print else None,
|
|
)
|
|
|
|
with out_json_updated.open(mode="w", encoding="UTF8") as fp:
|
|
json.dump(
|
|
self.util.feed_to_json(self.feed_updated),
|
|
fp,
|
|
indent=4 if self.config.pretty_print else None,
|
|
)
|