aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/docs/src/material/plugins/social
diff options
context:
space:
mode:
Diffstat (limited to 'docs/src/material/plugins/social')
-rw-r--r--docs/src/material/plugins/social/__init__.py19
-rw-r--r--docs/src/material/plugins/social/config.py48
-rw-r--r--docs/src/material/plugins/social/plugin.py516
3 files changed, 583 insertions, 0 deletions
diff --git a/docs/src/material/plugins/social/__init__.py b/docs/src/material/plugins/social/__init__.py
new file mode 100644
index 00000000..d1899378
--- /dev/null
+++ b/docs/src/material/plugins/social/__init__.py
@@ -0,0 +1,19 @@
+# Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com>
+
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to
+# deal in the Software without restriction, including without limitation the
+# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+# sell copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+# IN THE SOFTWARE.
diff --git a/docs/src/material/plugins/social/config.py b/docs/src/material/plugins/social/config.py
new file mode 100644
index 00000000..2d87c25e
--- /dev/null
+++ b/docs/src/material/plugins/social/config.py
@@ -0,0 +1,48 @@
+# Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com>
+
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to
+# deal in the Software without restriction, including without limitation the
+# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+# sell copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+# IN THE SOFTWARE.
+
+from mkdocs.config.base import Config
+from mkdocs.config.config_options import Deprecated, Type
+
+# -----------------------------------------------------------------------------
+# Classes
+# -----------------------------------------------------------------------------
+
+# Social plugin configuration
+class SocialConfig(Config):
+ enabled = Type(bool, default = True)
+ cache_dir = Type(str, default = ".cache/plugin/social")
+
+ # Settings for social cards
+ cards = Type(bool, default = True)
+ cards_dir = Type(str, default = "assets/images/social")
+ cards_layout_options = Type(dict, default = {})
+
+ # Deprecated settings
+ cards_color = Deprecated(
+ option_type = Type(dict, default = {}),
+ message =
+ "Deprecated, use 'cards_layout_options.background_color' "
+ "and 'cards_layout_options.color' with 'default' layout"
+ )
+ cards_font = Deprecated(
+ option_type = Type(str),
+ message = "Deprecated, use 'cards_layout_options.font_family'"
+ )
diff --git a/docs/src/material/plugins/social/plugin.py b/docs/src/material/plugins/social/plugin.py
new file mode 100644
index 00000000..3cdfa3ce
--- /dev/null
+++ b/docs/src/material/plugins/social/plugin.py
@@ -0,0 +1,516 @@
+# Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com>
+
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to
+# deal in the Software without restriction, including without limitation the
+# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+# sell copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+# IN THE SOFTWARE.
+
+# -----------------------------------------------------------------------------
+# Disclaimer
+# -----------------------------------------------------------------------------
+# Please note: this version of the social plugin is not actively development
+# anymore. Instead, Material for MkDocs Insiders ships a complete rewrite of
+# the plugin which is much more powerful and addresses all shortcomings of
+# this implementation. Additionally, the new social plugin allows to create
+# entirely custom social cards. You can probably imagine, that this was a lot
+# of work to pull off. If you run into problems, or want to have additional
+# functionality, please consider sponsoring the project. You can then use the
+# new version of the plugin immediately.
+# -----------------------------------------------------------------------------
+
+import concurrent.futures
+import functools
+import logging
+import os
+import posixpath
+import re
+import requests
+import sys
+
+from collections import defaultdict
+from hashlib import md5
+from io import BytesIO
+from mkdocs.commands.build import DuplicateFilter
+from mkdocs.exceptions import PluginError
+from mkdocs.plugins import BasePlugin
+from shutil import copyfile
+from tempfile import TemporaryFile
+from zipfile import ZipFile
+try:
+ from cairosvg import svg2png
+ from PIL import Image, ImageDraw, ImageFont
+except ImportError:
+ pass
+
+from .config import SocialConfig
+
+
+# -----------------------------------------------------------------------------
+# Classes
+# -----------------------------------------------------------------------------
+
+# Social plugin
+class SocialPlugin(BasePlugin[SocialConfig]):
+
+ def __init__(self):
+ self._executor = concurrent.futures.ThreadPoolExecutor(4)
+
+ # Retrieve configuration
+ def on_config(self, config):
+ self.color = colors.get("indigo")
+ self.config.cards = self.config.enabled
+ if not self.config.cards:
+ return
+
+ # Check dependencies
+ if "Image" not in globals():
+ raise PluginError(
+ "Required dependencies of \"social\" plugin not found. "
+ "Install with: pip install \"mkdocs-material[imaging]\""
+ )
+
+ # Move color options
+ if self.config.cards_color:
+
+ # Move background color to new option
+ value = self.config.cards_color.get("fill")
+ if value:
+ self.config.cards_layout_options["background_color"] = value
+
+ # Move color to new option
+ value = self.config.cards_color.get("text")
+ if value:
+ self.config.cards_layout_options["color"] = value
+
+ # Move font family to new option
+ if self.config.cards_font:
+ value = self.config.cards_font
+ self.config.cards_layout_options["font_family"] = value
+
+ # Check if site URL is defined
+ if not config.site_url:
+ log.warning(
+ "The \"site_url\" option is not set. The cards are generated, "
+ "but not linked, so they won't be visible on social media."
+ )
+
+ # Ensure presence of cache directory
+ self.cache = self.config.cache_dir
+ if not os.path.isdir(self.cache):
+ os.makedirs(self.cache)
+
+ # Retrieve palette from theme configuration
+ theme = config.theme
+ if "palette" in theme:
+ palette = theme["palette"]
+
+ # Use first palette, if multiple are defined
+ if isinstance(palette, list):
+ palette = palette[0]
+
+ # Set colors according to palette
+ if "primary" in palette and palette["primary"]:
+ primary = palette["primary"].replace(" ", "-")
+ self.color = colors.get(primary, self.color)
+
+ # Retrieve color overrides
+ options = self.config.cards_layout_options
+ self.color = {
+ "fill": options.get("background_color", self.color["fill"]),
+ "text": options.get("color", self.color["text"])
+ }
+
+ # Retrieve logo and font
+ self._resized_logo_promise = self._executor.submit(self._load_resized_logo, config)
+ self.font = self._load_font(config)
+
+ self._image_promises = []
+
+ # Create social cards
+ def on_page_markdown(self, markdown, page, config, files):
+ if not self.config.cards:
+ return
+
+ # Resolve image directory
+ directory = self.config.cards_dir
+ file, _ = os.path.splitext(page.file.src_path)
+
+ # Resolve path of image
+ path = "{}.png".format(os.path.join(
+ config.site_dir,
+ directory,
+ file
+ ))
+
+ # Resolve path of image directory
+ directory = os.path.dirname(path)
+ if not os.path.isdir(directory):
+ os.makedirs(directory)
+
+ # Compute site name
+ site_name = config.site_name
+
+ # Compute page title and description
+ title = page.meta.get("title", page.title)
+ description = config.site_description or ""
+ if "description" in page.meta:
+ description = page.meta["description"]
+
+ # Check type of meta title - see https://t.ly/m1Us
+ if not isinstance(title, str):
+ log.error(
+ f"Page meta title of page '{page.file.src_uri}' must be a "
+ f"string, but is of type \"{type(title)}\"."
+ )
+ sys.exit(1)
+
+ # Check type of meta description - see https://t.ly/m1Us
+ if not isinstance(description, str):
+ log.error(
+ f"Page meta description of '{page.file.src_uri}' must be a "
+ f"string, but is of type \"{type(description)}\"."
+ )
+ sys.exit(1)
+
+ # Generate social card if not in cache
+ hash = md5("".join([
+ site_name,
+ str(title),
+ description
+ ]).encode("utf-8"))
+ file = os.path.join(self.cache, f"{hash.hexdigest()}.png")
+ self._image_promises.append(self._executor.submit(
+ self._cache_image,
+ cache_path = file, dest_path = path,
+ render_function = lambda: self._render_card(site_name, title, description)
+ ))
+
+ # Inject meta tags into page
+ meta = page.meta.get("meta", [])
+ page.meta["meta"] = meta + self._generate_meta(page, config)
+
+ def on_post_build(self, config):
+ if not self.config.cards:
+ return
+
+ # Check for exceptions
+ for promise in self._image_promises:
+ promise.result()
+
+ # -------------------------------------------------------------------------
+
+ # Render image to cache (if not present), then copy from cache to site
+ def _cache_image(self, cache_path, dest_path, render_function):
+ if not os.path.isfile(cache_path):
+ image = render_function()
+ image.save(cache_path)
+
+ # Copy file from cache
+ copyfile(cache_path, dest_path)
+
+ @functools.lru_cache(maxsize=None)
+ def _get_font(self, kind, size):
+ return ImageFont.truetype(self.font[kind], size)
+
+ # Render social card
+ def _render_card(self, site_name, title, description):
+ # Render background and logo
+ image = self._render_card_background((1200, 630), self.color["fill"])
+ image.alpha_composite(
+ self._resized_logo_promise.result(),
+ (1200 - 228, 64 - 4)
+ )
+
+ # Render site name
+ font = self._get_font("Bold", 36)
+ image.alpha_composite(
+ self._render_text((826, 48), font, site_name, 1, 20),
+ (64 + 4, 64)
+ )
+
+ # Render page title
+ font = self._get_font("Bold", 92)
+ image.alpha_composite(
+ self._render_text((826, 328), font, title, 3, 30),
+ (64, 160)
+ )
+
+ # Render page description
+ font = self._get_font("Regular", 28)
+ image.alpha_composite(
+ self._render_text((826, 80), font, description, 2, 14),
+ (64 + 4, 512)
+ )
+
+ # Return social card image
+ return image
+
+ # Render social card background
+ def _render_card_background(self, size, fill):
+ return Image.new(mode = "RGBA", size = size, color = fill)
+
+ @functools.lru_cache(maxsize=None)
+ def _tmp_context(self):
+ image = Image.new(mode = "RGBA", size = (50, 50))
+ return ImageDraw.Draw(image)
+
+ @functools.lru_cache(maxsize=None)
+ def _text_bounding_box(self, text, font):
+ return self._tmp_context().textbbox((0, 0), text, font = font)
+
+ # Render social card text
+ def _render_text(self, size, font, text, lmax, spacing = 0):
+ width = size[0]
+ lines, words = [], []
+
+ # Remove remnant HTML tags
+ text = re.sub(r"(<[^>]+>)", "", text)
+
+ # Retrieve y-offset of textbox to correct for spacing
+ yoffset = 0
+
+ # Create drawing context and split text into lines
+ for word in text.split(" "):
+ combine = " ".join(words + [word])
+ textbox = self._text_bounding_box(combine, font = font)
+ yoffset = textbox[1]
+ if not words or textbox[2] <= width:
+ words.append(word)
+ else:
+ lines.append(words)
+ words = [word]
+
+ # Join words for each line and create image
+ lines.append(words)
+ lines = [" ".join(line) for line in lines]
+ image = Image.new(mode = "RGBA", size = size)
+
+ # Create drawing context and split text into lines
+ context = ImageDraw.Draw(image)
+ context.text(
+ (0, spacing / 2 - yoffset), "\n".join(lines[:lmax]),
+ font = font, fill = self.color["text"], spacing = spacing - yoffset
+ )
+
+ # Return text image
+ return image
+
+ # -------------------------------------------------------------------------
+
+ # Generate meta tags
+ def _generate_meta(self, page, config):
+ directory = self.config.cards_dir
+ file, _ = os.path.splitext(page.file.src_uri)
+
+ # Compute page title
+ title = page.meta.get("title", page.title)
+ if not page.is_homepage:
+ title = f"{title} - {config.site_name}"
+
+ # Compute page description
+ description = config.site_description
+ if "description" in page.meta:
+ description = page.meta["description"]
+
+ # Resolve image URL
+ url = "{}.png".format(posixpath.join(
+ config.site_url or ".",
+ directory,
+ file
+ ))
+
+ # Ensure forward slashes
+ url = url.replace(os.path.sep, "/")
+
+ # Return meta tags
+ return [
+
+ # Meta tags for Open Graph
+ { "property": "og:type", "content": "website" },
+ { "property": "og:title", "content": title },
+ { "property": "og:description", "content": description },
+ { "property": "og:image", "content": url },
+ { "property": "og:image:type", "content": "image/png" },
+ { "property": "og:image:width", "content": "1200" },
+ { "property": "og:image:height", "content": "630" },
+ { "property": "og:url", "content": page.canonical_url },
+
+ # Meta tags for Twitter
+ { "name": "twitter:card", "content": "summary_large_image" },
+ # { "name": "twitter:site", "content": user },
+ # { "name": "twitter:creator", "content": user },
+ { "name": "twitter:title", "content": title },
+ { "name": "twitter:description", "content": description },
+ { "name": "twitter:image", "content": url }
+ ]
+
+ def _load_resized_logo(self, config, width = 144):
+ logo = self._load_logo(config)
+ height = int(width * logo.height / logo.width)
+ return logo.resize((width, height))
+
+ # Retrieve logo image or icon
+ def _load_logo(self, config):
+ theme = config.theme
+
+ # Handle images (precedence over icons)
+ if "logo" in theme:
+ _, extension = os.path.splitext(theme["logo"])
+
+ path = os.path.join(config.docs_dir, theme["logo"])
+
+ # Allow users to put the logo inside their custom_dir (theme["logo"] case)
+ if theme.custom_dir:
+ custom_dir_logo = os.path.join(theme.custom_dir, theme["logo"])
+ if os.path.exists(custom_dir_logo):
+ path = custom_dir_logo
+
+ # Load SVG and convert to PNG
+ if extension == ".svg":
+ return self._load_logo_svg(path)
+
+ # Load PNG, JPEG, etc.
+ return Image.open(path).convert("RGBA")
+
+ # Handle icons
+ icon = theme["icon"] or {}
+ if "logo" in icon and icon["logo"]:
+ logo = icon["logo"]
+ else:
+ logo = "material/library"
+
+ # Resolve path of package
+ base = os.path.abspath(os.path.join(
+ os.path.dirname(__file__),
+ "../.."
+ ))
+
+ path = f"{base}/templates/.icons/{logo}.svg"
+
+ # Allow users to put the logo inside their custom_dir (theme["icon"]["logo"] case)
+ if theme.custom_dir:
+ custom_dir_logo = os.path.join(theme.custom_dir, ".icons", f"{logo}.svg")
+ if os.path.exists(custom_dir_logo):
+ path = custom_dir_logo
+
+ # Load icon data and fill with color
+ return self._load_logo_svg(path, self.color["text"])
+
+ # Load SVG file and convert to PNG
+ def _load_logo_svg(self, path, fill = None):
+ file = BytesIO()
+ data = open(path).read()
+
+ # Fill with color, if given
+ if fill:
+ data = data.replace("<svg", f"<svg fill=\"{fill}\"")
+
+ # Convert to PNG and return image
+ svg2png(bytestring = data, write_to = file, scale = 10)
+ return Image.open(file)
+
+ # Retrieve font
+ def _load_font(self, config):
+ name = self.config.cards_layout_options.get("font_family")
+ if not name:
+
+ # Retrieve from theme (default: Roboto)
+ theme = config.theme
+ if isinstance(theme["font"], dict) and "text" in theme["font"]:
+ name = theme["font"]["text"]
+ else:
+ name = "Roboto"
+
+ # Google fonts can return varients like OpenSans_Condensed-Regular.ttf so
+ # we only use the font requested e.g. OpenSans-Regular.ttf
+ font_filename_base = name.replace(' ', '')
+ filename_regex = re.escape(font_filename_base)+r"-(\w+)\.[ot]tf$"
+
+ font = {}
+ # Check for cached files - note these may be in subfolders
+ for currentpath, folders, files in os.walk(self.cache):
+ for file in files:
+ # Map available font weights to file paths
+ fname = os.path.join(currentpath, file)
+ match = re.search(filename_regex, fname)
+ if match:
+ font[match.group(1)] = fname
+
+ # If none found, fetch from Google and try again
+ if len(font) == 0:
+ self._load_font_from_google(name)
+ for currentpath, folders, files in os.walk(self.cache):
+ for file in files:
+ # Map available font weights to file paths
+ fname = os.path.join(currentpath, file)
+ match = re.search(filename_regex, fname)
+ if match:
+ font[match.group(1)] = fname
+
+ # Return available font weights with fallback
+ return defaultdict(lambda: font["Regular"], font)
+
+ # Retrieve font from Google Fonts
+ def _load_font_from_google(self, name):
+ url = "https://fonts.google.com/download?family={}"
+ res = requests.get(url.format(name.replace(" ", "+")), stream = True)
+
+ # Write archive to temporary file
+ tmp = TemporaryFile()
+ for chunk in res.iter_content(chunk_size = 32768):
+ tmp.write(chunk)
+
+ # Unzip fonts from temporary file
+ zip = ZipFile(tmp)
+ files = [file for file in zip.namelist() if file.endswith(".ttf") or file.endswith(".otf")]
+ zip.extractall(self.cache, files)
+
+ # Close and delete temporary file
+ tmp.close()
+ return files
+
+# -----------------------------------------------------------------------------
+# Data
+# -----------------------------------------------------------------------------
+
+# Set up logging
+log = logging.getLogger("mkdocs")
+log.addFilter(DuplicateFilter())
+
+# Color palette
+colors = dict({
+ "red": { "fill": "#ef5552", "text": "#ffffff" },
+ "pink": { "fill": "#e92063", "text": "#ffffff" },
+ "purple": { "fill": "#ab47bd", "text": "#ffffff" },
+ "deep-purple": { "fill": "#7e56c2", "text": "#ffffff" },
+ "indigo": { "fill": "#4051b5", "text": "#ffffff" },
+ "blue": { "fill": "#2094f3", "text": "#ffffff" },
+ "light-blue": { "fill": "#02a6f2", "text": "#ffffff" },
+ "cyan": { "fill": "#00bdd6", "text": "#ffffff" },
+ "teal": { "fill": "#009485", "text": "#ffffff" },
+ "green": { "fill": "#4cae4f", "text": "#ffffff" },
+ "light-green": { "fill": "#8bc34b", "text": "#ffffff" },
+ "lime": { "fill": "#cbdc38", "text": "#000000" },
+ "yellow": { "fill": "#ffec3d", "text": "#000000" },
+ "amber": { "fill": "#ffc105", "text": "#000000" },
+ "orange": { "fill": "#ffa724", "text": "#000000" },
+ "deep-orange": { "fill": "#ff6e42", "text": "#ffffff" },
+ "brown": { "fill": "#795649", "text": "#ffffff" },
+ "grey": { "fill": "#757575", "text": "#ffffff" },
+ "blue-grey": { "fill": "#546d78", "text": "#ffffff" },
+ "black": { "fill": "#000000", "text": "#ffffff" },
+ "white": { "fill": "#ffffff", "text": "#000000" }
+})