diff --git a/.gitignore b/.gitignore index b2b6040..2e8bec9 100644 --- a/.gitignore +++ b/.gitignore @@ -286,4 +286,7 @@ $RECYCLE.BIN/ # Windows shortcuts *.lnk -# End of https://www.toptal.com/developers/gitignore/api/windows,linux,python,pycharm,visualstudiocode \ No newline at end of file +# End of https://www.toptal.com/developers/gitignore/api/windows,linux,python,pycharm,visualstudiocode + +# Assets that are built by sphinx +docs/tmp \ No newline at end of file diff --git a/README.md b/README.md index 215f9e0..af82e73 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![Build](https://github.com/wpilibsuite/sphinxext-opengraph/workflows/Test%20and%20Deploy/badge.svg)](https://github.com/wpilibsuite/sphinxext-opengraph/actions) [![Code style: Black](https://img.shields.io/badge/code%20style-Black-000000.svg)](https://github.com/psf/black) -Sphinx extension to generate [Open Graph metadata](https://ogp.me/). +Sphinx extension to generate [Open Graph metadata](https://ogp.me/) for each page of your documentation. ## Installation @@ -30,6 +30,9 @@ Users hosting documentation on Read The Docs *do not* need to set any of the fol * Configure the amount of characters taken from a page. The default of 200 is probably good for most people. If something other than a number is used, it defaults back to 200. * `ogp_site_name` * This is not required. Name of the site. This is displayed above the title. Defaults to the Sphinx [`project`](https://www.sphinx-doc.org/en/master/usage/configuration.html#confval-project) config value. Set to `False` to unset and use no default. +* `ogp_social_cards` + * Configuration for automatically creating social media card PNGs for each page. + For more information, see [the social media cards docs](docs/source/socialcards.md). * `ogp_image` * This is not required. Link to image to show. Note that all relative paths are converted to be relative to the root of the html output as defined by `ogp_site_url`. * `ogp_image_alt` diff --git a/dev-requirements.txt b/dev-requirements.txt index 90b2d39..2541773 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,5 +1,6 @@ sphinx +matplotlib wheel==0.37.1 pytest==7.1.3 beautifulsoup4==4.11.1 -setuptools==65.4.1 \ No newline at end of file +setuptools==65.4.1 diff --git a/docs/requirements.txt b/docs/requirements.txt index cdb43cc..5bfb0f2 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,4 +1,5 @@ myst-parser==0.18.1 furo==2022.9.29 sphinx==5.2.3 +sphinx-design ./ diff --git a/docs/script/generate_social_card_previews.py b/docs/script/generate_social_card_previews.py new file mode 100644 index 0000000..eb68686 --- /dev/null +++ b/docs/script/generate_social_card_previews.py @@ -0,0 +1,80 @@ +""" +A helper script to test out what social previews look like. +I should remove this when I'm happy with the result. +""" +# %load_ext autoreload +# %autoreload 2 + +from pathlib import Path +from textwrap import dedent +from sphinxext.opengraph.socialcards import ( + render_social_card, + MAX_CHAR_PAGE_TITLE, + MAX_CHAR_DESCRIPTION, +) +import random + +here = Path(__file__).parent + +# Dummy lorem text +lorem = """ +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 +""".split() # noqa + +kwargs_fig = dict( + image=here / "../source/_static/og-logo.png", + image_mini=here / "../../sphinxext/opengraph/_static/sphinx-logo-shadow.png", +) + +print("Generating previews of social media cards...") +plt_objects = None +embed_text = [] +for perm in range(20): + # Create dummy text description and pagetitle for this iteration + random.shuffle(lorem) + title = " ".join(lorem[:100]) + title = title[: MAX_CHAR_PAGE_TITLE - 3] + "..." + + random.shuffle(lorem) + desc = " ".join(lorem[:100]) + desc = desc[: MAX_CHAR_DESCRIPTION - 3] + "..." + + path_tmp = Path(here / "../tmp") + path_tmp.mkdir(exist_ok=True) + path_out = Path(path_tmp / f"num_{perm}.png") + + plt_objects = render_social_card( + path=path_out, + site_title="Sphinx Social Card Demo", + page_title=title, + description=desc, + siteurl="sphinxext-opengraph.readthedocs.io", + plt_objects=plt_objects, + kwargs_fig=kwargs_fig, + ) + + path_examples_page_folder = here / ".." + embed_text.append( + dedent( + f""" + ````{{grid-item}} + ```{{image}} ../{path_out.relative_to(path_examples_page_folder)} + ``` + ```` + """ + ) + ) + +embed_text = "\n".join(embed_text) +embed_text = f""" +`````{{grid}} 2 +:gutter: 5 + +{embed_text} +````` +""" + +# Write markdown text that we can use to embed these images in the docs +(here / "../tmp/embed.txt").write_text(embed_text) + +print("Done generating previews of social media cards...") diff --git a/docs/source/_static/og-logo.png b/docs/source/_static/og-logo.png new file mode 100644 index 0000000..c83e8b1 Binary files /dev/null and b/docs/source/_static/og-logo.png differ diff --git a/docs/source/conf.py b/docs/source/conf.py index 56d293c..eb612c8 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -12,6 +12,7 @@ # import os import sys +from subprocess import run sys.path.insert(0, os.path.abspath("../..")) @@ -33,6 +34,7 @@ # ones. extensions = [ "myst_parser", + "sphinx_design", "sphinxext.opengraph", ] @@ -49,4 +51,23 @@ # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # +html_title = "sphinxext-opengraph" +html_logo = "_static/og-logo.png" html_theme = "furo" + + +# -- Configuration for this theme -------------------------------------------- + +ogp_site_url = "https://sphinxext-opengraph.readthedocs.io/en/latest/" + +# Configuration for testing but generally we use the defaults +# Uncomment lines to see their effect. +ogp_social_cards = { + "site_url": "sphinxext-opengraph.readthedocs.io", + # "image": "TODO: add another image to test", + # "line_color": "#4078c0", +} + +# Generate sample social media preview images +path_script = os.path.abspath("../script/generate_social_card_previews.py") +run(f"python {path_script}", shell=True) diff --git a/docs/source/index.md b/docs/source/index.md index 7dcb710..dfb3ac6 100644 --- a/docs/source/index.md +++ b/docs/source/index.md @@ -1,3 +1,9 @@ ```{include} ../../README.md :relative-images: +:relative-docs: docs/source +``` + +```{toctree} +:hidden: +socialcards ``` diff --git a/docs/source/socialcards.md b/docs/source/socialcards.md new file mode 100644 index 0000000..497d08e --- /dev/null +++ b/docs/source/socialcards.md @@ -0,0 +1,41 @@ +# Social media card images + +This extension will automatically generate a PNG meant for sharing documentation links on social media platforms. +These cards display metadata about the page that you link to, and are meant to catch the attention of readers. + +See [the opengraph.xyz website](https://www.opengraph.xyz/) for a way to preview what your social media cards look like. +Here's an example of what the card for this page looks like: + +% This is auto-generated at build time +```{image} ../tmp//num_0.png +:width: 500 +``` + +## Disable card images + +To disable social media card images, use the following configuration: + +```{code-block} python +:caption: conf.py + +ogp_social_cards = { + "enable": False +} +``` + +## Customize the card + +There are several customization options to change the text and look of the social media preview card. +Below is a summary of these options. + +- **`site_url`**: Set a custom site URL. +- **`image`**: Over-ride the top-right image (by default, `html_logo` is used). +- **`line_color`**: Color of the border line at the bottom of the card, in hex format. +% TODO: add an over-ride for each part of the card. + +## Example social cards + +Below are several social cards to give an idea for how this extension behaves with different length and size of text. + +```{include} ../tmp/embed.txt +``` diff --git a/noxfile.py b/noxfile.py new file mode 100644 index 0000000..acfbb14 --- /dev/null +++ b/noxfile.py @@ -0,0 +1,38 @@ +""" +Configuration to automatically run jobs and tests via `nox`. +For example, to build the documentation with a live server: + + nox -s docs -- live + +List available jobs: + + nox -l + +ref: https://nox.thea.codes/ +""" +import nox +from shlex import split + +nox.options.reuse_existing_virtualenvs = True + + +@nox.session +def docs(session): + """Build the documentation. Use `-- live` to build with a live server.""" + session.install("-e", ".") + session.install("-r", "docs/requirements.txt") + if "live" in session.posargs: + session.install("ipython") + session.install("sphinx-autobuild") + session.run(*split("sphinx-autobuild -b html docs/source docs/build/html")) + else: + session.run( + *split("sphinx-build -nW --keep-going -b html docs/source docs/build/html") + ) + + +@nox.session +def test(session): + """Run the test suite.""" + session.install(".") + session.run(*(["pytest"] + session.posargs)) diff --git a/setup.py b/setup.py index fd969e9..1799775 100644 --- a/setup.py +++ b/setup.py @@ -14,8 +14,10 @@ long_description_content_type="text/markdown", url="https://github.com/wpilibsuite/sphinxext-opengraph", license="LICENSE.md", - install_requires=["sphinx>=4.0"], + install_requires=["sphinx>=4.0", "matplotlib"], packages=["sphinxext/opengraph"], + include_package_data=True, + package_data={"sphinxext.opengraph": ["sphinxext/opengraph/_static/*"]}, classifiers=[ "Development Status :: 5 - Production/Stable", "Environment :: Plugins", diff --git a/sphinxext/opengraph/__init__.py b/sphinxext/opengraph/__init__.py index c560357..16aac8e 100644 --- a/sphinxext/opengraph/__init__.py +++ b/sphinxext/opengraph/__init__.py @@ -8,11 +8,14 @@ from .descriptionparser import get_description from .metaparser import get_meta_description from .titleparser import get_title +from .socialcards import create_social_card, DEFAULT_SOCIAL_CONFIG import os DEFAULT_DESCRIPTION_LENGTH = 200 +DEFAULT_DESCRIPTION_LENGTH_SOCIAL_CARDS = 160 +DEFAULT_PAGE_LENGTH_SOCIAL_CARDS = 80 # A selection from https://www.iana.org/assignments/media-types/media-types.xhtml#image IMAGE_MIME_TYPES = { @@ -127,10 +130,66 @@ def get_tags( ogp_use_first_image = config["ogp_use_first_image"] ogp_image_alt = fields.get("og:image:alt", config["ogp_image_alt"]) + # Decide whether to add social media card images for each page. + # Only do this as a fallback if the user hasn't given any configuration + # to add other images. + config_social = DEFAULT_SOCIAL_CONFIG.copy() + social_card_user_options = app.config.ogp_social_cards or {} + config_social.update(social_card_user_options) + + # This will only be False if the user explicitly sets it + if ( + not (image_url or ogp_use_first_image) + and config_social.get("enable") is not False + ): + # Description + description_max_length = config_social.get( + "description_max_length", DEFAULT_DESCRIPTION_LENGTH_SOCIAL_CARDS - 3 + ) + if len(description) > description_max_length: + description = description[:description_max_length].strip() + "..." + + # Page title + pagetitle = title + if len(pagetitle) > DEFAULT_PAGE_LENGTH_SOCIAL_CARDS: + pagetitle = pagetitle[:DEFAULT_PAGE_LENGTH_SOCIAL_CARDS] + "..." + + # Site URL + site_url = config_social.get("site_url", True) + if site_url is True: + url_text = app.config.ogp_site_url.split("://")[-1] + elif isinstance(site_url, str): + url_text = site_url + + # Plot an image with the given metadata to the output path + image_path = create_social_card( + app, + config_social, + site_name, + pagetitle, + description, + url_text, + context["pagename"], + ) + ogp_use_first_image = False + + # Alt text is taken from description unless given + if "og:image:alt" in fields: + ogp_image_alt = fields.get("og:image:alt") + else: + ogp_image_alt = description + + # Link the image in our page metadata + # We use os.path.sep to standardize behavior acros *nix and Windows + url = app.config.ogp_site_url.strip("/") + image_path = str(image_path).replace(os.path.sep, "/").strip("/") + image_url = f"{url}/{image_path}" + fields.pop("og:image:alt", None) first_image = None if ogp_use_first_image: + # Use the first image that is defined in the current page first_image = doctree.next_node(nodes.image) if ( first_image @@ -165,6 +224,12 @@ def get_tags( elif ogp_image_alt is None and title: tags["og:image:alt"] = title + if "ogp_social_card_tags" in context: + # Add social media metadata if we've activated preview cards + tags["og:image:width"] = meta["width"] + tags["og:image:height"] = meta["height"] + meta_tags["twitter:card"] = "summary_large_image" + # arbitrary tags and overrides tags.update({k: v for k, v in fields.items() if k.startswith("og:")}) @@ -199,9 +264,11 @@ def setup(app: Sphinx) -> Dict[str, Any]: app.add_config_value("ogp_use_first_image", False, "html") app.add_config_value("ogp_type", "website", "html") app.add_config_value("ogp_site_name", None, "html") + app.add_config_value("ogp_social_cards", None, "html") app.add_config_value("ogp_custom_meta_tags", [], "html") app.add_config_value("ogp_enable_meta_description", True, "html") + # Main Sphinx OpenGraph linking app.connect("html-page-context", html_page_context) return { diff --git a/sphinxext/opengraph/_static/Roboto-Flex.ttf b/sphinxext/opengraph/_static/Roboto-Flex.ttf new file mode 100644 index 0000000..bd32e0d Binary files /dev/null and b/sphinxext/opengraph/_static/Roboto-Flex.ttf differ diff --git a/sphinxext/opengraph/_static/sphinx-logo-shadow.png b/sphinxext/opengraph/_static/sphinx-logo-shadow.png new file mode 100644 index 0000000..58c1f39 Binary files /dev/null and b/sphinxext/opengraph/_static/sphinx-logo-shadow.png differ diff --git a/sphinxext/opengraph/socialcards.py b/sphinxext/opengraph/socialcards.py new file mode 100644 index 0000000..798d497 --- /dev/null +++ b/sphinxext/opengraph/socialcards.py @@ -0,0 +1,293 @@ +"""Build a PNG card for each page meant for social media.""" +import hashlib +from pathlib import Path +import matplotlib +from matplotlib import pyplot as plt +import matplotlib.image as mpimg + +matplotlib.use("agg") + + +HERE = Path(__file__).parent +MAX_CHAR_PAGE_TITLE = 75 +MAX_CHAR_DESCRIPTION = 175 + +# Default configuration for this functionality +DEFAULT_SOCIAL_CONFIG = { + "enable": True, + "site_url": True, + "site_title": True, + "page_title": True, + "description": True, +} + + +# Default configuration for the figure style +DEFAULT_KWARGS_FIG = { + "enable": True, + "site_url": True, +} + + +# These functions are used when creating social card objects to set MPL values. +# They must be defined here otherwise Sphinx errors when trying to pickle them. +# They are dependent on the `multiple` variable defined when the figure is created. +# Because they are depending on the figure size and renderer used to generate them. +def _set_page_title_line_width(): + return 825 + + +def _set_description_line_width(): + return 1000 + + +def create_social_card( + app, config_social, site_name, page_title, description, url_text, page_path +): + """Create a social preview card according to page metadata. + + This uses page metadata and calls a render function to generate the image. + It also passes configuration through to the rendering function. + If Matplotlib objects are present in the `app` environment, it reuses them. + """ + + # Add a hash to the image path based on metadata to bust caches + # ref: https://developer.twitter.com/en/docs/twitter-for-websites/cards/guides/troubleshooting-cards#refreshing_images # noqa + hash = hashlib.sha1( + (site_name + page_title + description + str(config_social)).encode() + ).hexdigest()[:8] + + # Define the file path we'll use for this image + path_images_relative = Path("_images/social_previews") + filename_image = f"summary_{page_path.replace('/', '_')}_{hash}.png" + + # Absolute path used to save the image + path_images_absolute = Path(app.builder.outdir) / path_images_relative + path_images_absolute.mkdir(exist_ok=True, parents=True) + path_image = path_images_absolute / filename_image + + # If the image already exists then we can just skip creating a new one. + # This is because we hash the values of the text + images in the social card. + # If the hash doesn't change, it means the output should be the same. + if path_image.exists(): + return + + # These kwargs are used to generate the base figure image + kwargs_fig = {} + + # Large image to the top right + if config_social.get("image"): + kwargs_fig["image"] = Path(app.builder.srcdir) / config_social.get("image") + elif app.config.html_logo: + kwargs_fig["image"] = Path(app.builder.srcdir) / app.config.html_logo + + # Mini image to the bottom right + if config_social.get("image_mini"): + kwargs_fig["image_mini"] = Path(app.builder.srcdir) / config_social.get( + "image_mini" + ) + else: + kwargs_fig["image_mini"] = ( + Path(__file__).parent / "_static/sphinx-logo-shadow.png" + ) + + # These are passed directly from the user configuration to our plotting function + pass_through_config = ["text_color", "line_color", "background_color", "font"] + for config in pass_through_config: + if config_social.get(config): + kwargs_fig[config] = config_social.get(config) + + # Generate the image and store the matplotlib objects so that we can re-use them + if hasattr(app.env, "ogp_social_card_plt_objects"): + plt_objects = app.env.ogp_social_card_plt_objects + else: + plt_objects = None + plt_objects = render_social_card( + path_image, + site_name, + page_title, + description, + url_text, + plt_objects, + kwargs_fig, + ) + app.env.ogp_social_card_plt_objects = plt_objects + + # Path relative to build folder will be what we use for linking the URL + path_relative_to_build = path_images_relative / filename_image + return path_relative_to_build + + +def render_social_card( + path, + site_title=None, + page_title=None, + description=None, + siteurl=None, + plt_objects=None, + kwargs_fig=None, +): + """Render a social preview card with Matplotlib and write to disk.""" + # If objects is None it means this is the first time plotting. + # Create the figure objects and return them so that we re-use them later. + if plt_objects is None: + ( + fig, + txt_site_title, + txt_page_title, + txt_description, + txt_url, + ) = create_social_card_objects(**kwargs_fig) + else: + fig, txt_site_title, txt_page_title, txt_description, txt_url = plt_objects + + # Update the matplotlib text objects with new text from this page + txt_site_title.set_text(site_title) + txt_page_title.set_text(page_title) + txt_description.set_text(description) + txt_url.set_text(siteurl) + + # Save the image + fig.savefig(path, facecolor=None) + return fig, txt_site_title, txt_page_title, txt_description, txt_url + + +def create_social_card_objects( + image=None, + image_mini=None, + page_title_color="#2f363d", + description_color="#585e63", + site_title_color="#585e63", + site_url_color="#2f363d", + background_color="white", + line_color="#5A626B", + font="Roboto", +): + """Create the Matplotlib objects for the first time.""" + # Load the Roboto font + # TODO: Currently the `font` parameter above does nothing + # Should instead make it possible to load remote fonts or local fonts + # if a user specifies. + path_font = Path(__file__).parent / "_static/Roboto-flex.ttf" + font = matplotlib.font_manager.FontEntry(fname=str(path_font), name="Roboto") + matplotlib.font_manager.fontManager.ttflist.append(font) + + # Because Matplotlib doesn't let you specify figures in pixels, only inches + # This `multiple` results in a scale of about 1146px by 600px + # Which is roughly the recommended size for OpenGraph images + # ref: https://opengraph.xyz + ratio = 1200 / 628 + multiple = 6 + fig = plt.figure(figsize=(ratio * multiple, multiple)) + fig.set_facecolor(background_color) + + # Text axis + axtext = fig.add_axes((0, 0, 1, 1)) + + # Image axis + ax_x, ax_y, ax_w, ax_h = (0.65, 0.65, 0.3, 0.3) + axim_logo = fig.add_axes((ax_x, ax_y, ax_w, ax_h), anchor="NE") + + # Image mini axis + ax_x, ax_y, ax_w, ax_h = (0.82, 0.1, 0.1, 0.1) + axim_mini = fig.add_axes((ax_x, ax_y, ax_w, ax_h), anchor="NE") + + # Line at the bottom axis + axline = fig.add_axes((-0.1, -0.04, 1.2, 0.1)) + + # Axes configuration + left_margin = 0.05 + with plt.rc_context({"font.family": font.name}): + # Site title + # Smaller font, just above page title + site_title_y_offset = 0.87 + txt_site = axtext.text( + left_margin, + site_title_y_offset, + "Test site title", + { + "size": 24, + }, + ha="left", + va="top", + wrap=True, + c=site_title_color, + ) + + # Page title + # A larger font for more visibility + page_title_y_offset = 0.77 + + txt_page = axtext.text( + left_margin, + page_title_y_offset, + "Test page title, a bit longer to demo", + {"size": 46, "color": "k", "fontweight": "bold"}, + ha="left", + va="top", + wrap=True, + c=page_title_color, + ) + + txt_page._get_wrap_line_width = _set_page_title_line_width + + # description + # Just below site title, smallest font and many lines. + # Our target length is 160 characters, so it should be + # two lines at full width with some room to spare at this length. + description_y_offset = 0.2 + txt_description = axtext.text( + left_margin, + description_y_offset, + ( + "A longer description that we use to ," + "show off what the descriptions look like." + ), + {"size": 17}, + ha="left", + va="bottom", + wrap=True, + c=description_color, + ) + txt_description._get_wrap_line_width = _set_description_line_width + + # url + # Aligned to the left of the mini image + url_y_axis_ofset = 0.12 + txt_url = axtext.text( + left_margin, + url_y_axis_ofset, + "testurl.org", + {"size": 22}, + ha="left", + va="bottom", + fontweight="bold", + c=site_url_color, + ) + + if image_mini: + img = mpimg.imread(image_mini) + axim_mini.imshow(img) + + # Put the logo in the top right if it exists + if image: + img = mpimg.imread(image) + yw, xw = img.shape[:2] + + # Axis is square and width is longest image axis + longest = max([yw, xw]) + axim_logo.set_xlim([0, longest]) + axim_logo.set_ylim([longest, 0]) + + # Center it on the non-long axis + xdiff = (longest - xw) / 2 + ydiff = (longest - yw) / 2 + axim_logo.imshow(img, extent=[xdiff, xw + xdiff, yw + ydiff, ydiff]) + + # Put a colored line at the bottom of the figure + axline.hlines(0, 0, 1, lw=25, color=line_color) + + # Remove the ticks and borders from all axes for a clean look + for ax in fig.axes: + ax.set_axis_off() + return fig, txt_site, txt_page, txt_description, txt_url diff --git a/tests/test_options.py b/tests/test_options.py index 1d60125..195cb00 100644 --- a/tests/test_options.py +++ b/tests/test_options.py @@ -92,6 +92,21 @@ def test_image_alt(og_meta_tags): assert get_tag_content(og_meta_tags, "image:alt") == "Example's Docs!" +@pytest.mark.sphinx("html", testroot="simple") +def test_image_social_cards(og_meta_tags): + """Social cards should automatically be added if no og:image is given.""" + # Asserting `in` instead of `==` because of the hash that is generated + assert ( + "http://example.org/en/latest/_images/social_previews/summary_index" + in get_tag_content(og_meta_tags, "image") + ) + # Image alt text should be taken from page content. + assert ( + "Lorem ipsum dolor sit amet, consectetur adipiscing elit." + in get_tag_content(og_meta_tags, "image:alt") + ) + + @pytest.mark.sphinx("html", testroot="type") def test_type(og_meta_tags): assert get_tag_content(og_meta_tags, "type") == "article"