Skip to content

Commit 99f2521

Browse files
authored
ENH: Add social card previews (#88)
* First pass at adding social card previews * Refactor social card generation * Fixing tests * Document nox * Package data * Fix plt objects path * Add matplotlib to dev requirements * Run black * Fix path separator * Blackify everything * Extra strip * Strip image path * Oh god please fix it * Strips
1 parent 1c4aadb commit 99f2521

16 files changed

+575
-4
lines changed

.gitignore

+4-1
Original file line numberDiff line numberDiff line change
@@ -286,4 +286,7 @@ $RECYCLE.BIN/
286286
# Windows shortcuts
287287
*.lnk
288288

289-
# End of https://www.toptal.com/developers/gitignore/api/windows,linux,python,pycharm,visualstudiocode
289+
# End of https://www.toptal.com/developers/gitignore/api/windows,linux,python,pycharm,visualstudiocode
290+
291+
# Assets that are built by sphinx
292+
docs/tmp

README.md

+4-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
[![Build](https://github.com/wpilibsuite/sphinxext-opengraph/workflows/Test%20and%20Deploy/badge.svg)](https://github.com/wpilibsuite/sphinxext-opengraph/actions)
44
[![Code style: Black](https://img.shields.io/badge/code%20style-Black-000000.svg)](https://github.com/psf/black)
55

6-
Sphinx extension to generate [Open Graph metadata](https://ogp.me/).
6+
Sphinx extension to generate [Open Graph metadata](https://ogp.me/) for each page of your documentation.
77

88
## Installation
99

@@ -30,6 +30,9 @@ Users hosting documentation on Read The Docs *do not* need to set any of the fol
3030
* 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.
3131
* `ogp_site_name`
3232
* 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.
33+
* `ogp_social_cards`
34+
* Configuration for automatically creating social media card PNGs for each page.
35+
For more information, see [the social media cards docs](docs/source/socialcards.md).
3336
* `ogp_image`
3437
* 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`.
3538
* `ogp_image_alt`

dev-requirements.txt

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
sphinx
2+
matplotlib
23
wheel==0.37.1
34
pytest==7.1.3
45
beautifulsoup4==4.11.1
5-
setuptools==65.4.1
6+
setuptools==65.4.1

docs/requirements.txt

+1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
myst-parser==0.18.1
22
furo==2022.9.29
33
sphinx==5.2.3
4+
sphinx-design
45
./
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""
2+
A helper script to test out what social previews look like.
3+
I should remove this when I'm happy with the result.
4+
"""
5+
# %load_ext autoreload
6+
# %autoreload 2
7+
8+
from pathlib import Path
9+
from textwrap import dedent
10+
from sphinxext.opengraph.socialcards import (
11+
render_social_card,
12+
MAX_CHAR_PAGE_TITLE,
13+
MAX_CHAR_DESCRIPTION,
14+
)
15+
import random
16+
17+
here = Path(__file__).parent
18+
19+
# Dummy lorem text
20+
lorem = """
21+
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
22+
""".split() # noqa
23+
24+
kwargs_fig = dict(
25+
image=here / "../source/_static/og-logo.png",
26+
image_mini=here / "../../sphinxext/opengraph/_static/sphinx-logo-shadow.png",
27+
)
28+
29+
print("Generating previews of social media cards...")
30+
plt_objects = None
31+
embed_text = []
32+
for perm in range(20):
33+
# Create dummy text description and pagetitle for this iteration
34+
random.shuffle(lorem)
35+
title = " ".join(lorem[:100])
36+
title = title[: MAX_CHAR_PAGE_TITLE - 3] + "..."
37+
38+
random.shuffle(lorem)
39+
desc = " ".join(lorem[:100])
40+
desc = desc[: MAX_CHAR_DESCRIPTION - 3] + "..."
41+
42+
path_tmp = Path(here / "../tmp")
43+
path_tmp.mkdir(exist_ok=True)
44+
path_out = Path(path_tmp / f"num_{perm}.png")
45+
46+
plt_objects = render_social_card(
47+
path=path_out,
48+
site_title="Sphinx Social Card Demo",
49+
page_title=title,
50+
description=desc,
51+
siteurl="sphinxext-opengraph.readthedocs.io",
52+
plt_objects=plt_objects,
53+
kwargs_fig=kwargs_fig,
54+
)
55+
56+
path_examples_page_folder = here / ".."
57+
embed_text.append(
58+
dedent(
59+
f"""
60+
````{{grid-item}}
61+
```{{image}} ../{path_out.relative_to(path_examples_page_folder)}
62+
```
63+
````
64+
"""
65+
)
66+
)
67+
68+
embed_text = "\n".join(embed_text)
69+
embed_text = f"""
70+
`````{{grid}} 2
71+
:gutter: 5
72+
73+
{embed_text}
74+
`````
75+
"""
76+
77+
# Write markdown text that we can use to embed these images in the docs
78+
(here / "../tmp/embed.txt").write_text(embed_text)
79+
80+
print("Done generating previews of social media cards...")

docs/source/_static/og-logo.png

4.6 KB
Loading

docs/source/conf.py

+21
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
#
1313
import os
1414
import sys
15+
from subprocess import run
1516

1617
sys.path.insert(0, os.path.abspath("../.."))
1718

@@ -33,6 +34,7 @@
3334
# ones.
3435
extensions = [
3536
"myst_parser",
37+
"sphinx_design",
3638
"sphinxext.opengraph",
3739
]
3840

@@ -49,4 +51,23 @@
4951
# The theme to use for HTML and HTML Help pages. See the documentation for
5052
# a list of builtin themes.
5153
#
54+
html_title = "sphinxext-opengraph"
55+
html_logo = "_static/og-logo.png"
5256
html_theme = "furo"
57+
58+
59+
# -- Configuration for this theme --------------------------------------------
60+
61+
ogp_site_url = "https://sphinxext-opengraph.readthedocs.io/en/latest/"
62+
63+
# Configuration for testing but generally we use the defaults
64+
# Uncomment lines to see their effect.
65+
ogp_social_cards = {
66+
"site_url": "sphinxext-opengraph.readthedocs.io",
67+
# "image": "TODO: add another image to test",
68+
# "line_color": "#4078c0",
69+
}
70+
71+
# Generate sample social media preview images
72+
path_script = os.path.abspath("../script/generate_social_card_previews.py")
73+
run(f"python {path_script}", shell=True)

docs/source/index.md

+6
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
11
```{include} ../../README.md
22
:relative-images:
3+
:relative-docs: docs/source
4+
```
5+
6+
```{toctree}
7+
:hidden:
8+
socialcards
39
```

docs/source/socialcards.md

+41
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# Social media card images
2+
3+
This extension will automatically generate a PNG meant for sharing documentation links on social media platforms.
4+
These cards display metadata about the page that you link to, and are meant to catch the attention of readers.
5+
6+
See [the opengraph.xyz website](https://www.opengraph.xyz/) for a way to preview what your social media cards look like.
7+
Here's an example of what the card for this page looks like:
8+
9+
% This is auto-generated at build time
10+
```{image} ../tmp//num_0.png
11+
:width: 500
12+
```
13+
14+
## Disable card images
15+
16+
To disable social media card images, use the following configuration:
17+
18+
```{code-block} python
19+
:caption: conf.py
20+
21+
ogp_social_cards = {
22+
"enable": False
23+
}
24+
```
25+
26+
## Customize the card
27+
28+
There are several customization options to change the text and look of the social media preview card.
29+
Below is a summary of these options.
30+
31+
- **`site_url`**: Set a custom site URL.
32+
- **`image`**: Over-ride the top-right image (by default, `html_logo` is used).
33+
- **`line_color`**: Color of the border line at the bottom of the card, in hex format.
34+
% TODO: add an over-ride for each part of the card.
35+
36+
## Example social cards
37+
38+
Below are several social cards to give an idea for how this extension behaves with different length and size of text.
39+
40+
```{include} ../tmp/embed.txt
41+
```

noxfile.py

+38
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
"""
2+
Configuration to automatically run jobs and tests via `nox`.
3+
For example, to build the documentation with a live server:
4+
5+
nox -s docs -- live
6+
7+
List available jobs:
8+
9+
nox -l
10+
11+
ref: https://nox.thea.codes/
12+
"""
13+
import nox
14+
from shlex import split
15+
16+
nox.options.reuse_existing_virtualenvs = True
17+
18+
19+
@nox.session
20+
def docs(session):
21+
"""Build the documentation. Use `-- live` to build with a live server."""
22+
session.install("-e", ".")
23+
session.install("-r", "docs/requirements.txt")
24+
if "live" in session.posargs:
25+
session.install("ipython")
26+
session.install("sphinx-autobuild")
27+
session.run(*split("sphinx-autobuild -b html docs/source docs/build/html"))
28+
else:
29+
session.run(
30+
*split("sphinx-build -nW --keep-going -b html docs/source docs/build/html")
31+
)
32+
33+
34+
@nox.session
35+
def test(session):
36+
"""Run the test suite."""
37+
session.install(".")
38+
session.run(*(["pytest"] + session.posargs))

setup.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@
1414
long_description_content_type="text/markdown",
1515
url="https://github.com/wpilibsuite/sphinxext-opengraph",
1616
license="LICENSE.md",
17-
install_requires=["sphinx>=4.0"],
17+
install_requires=["sphinx>=4.0", "matplotlib"],
1818
packages=["sphinxext/opengraph"],
19+
include_package_data=True,
20+
package_data={"sphinxext.opengraph": ["sphinxext/opengraph/_static/*"]},
1921
classifiers=[
2022
"Development Status :: 5 - Production/Stable",
2123
"Environment :: Plugins",

sphinxext/opengraph/__init__.py

+67
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,14 @@
88
from .descriptionparser import get_description
99
from .metaparser import get_meta_description
1010
from .titleparser import get_title
11+
from .socialcards import create_social_card, DEFAULT_SOCIAL_CONFIG
1112

1213
import os
1314

1415

1516
DEFAULT_DESCRIPTION_LENGTH = 200
17+
DEFAULT_DESCRIPTION_LENGTH_SOCIAL_CARDS = 160
18+
DEFAULT_PAGE_LENGTH_SOCIAL_CARDS = 80
1619

1720
# A selection from https://www.iana.org/assignments/media-types/media-types.xhtml#image
1821
IMAGE_MIME_TYPES = {
@@ -127,10 +130,66 @@ def get_tags(
127130
ogp_use_first_image = config["ogp_use_first_image"]
128131
ogp_image_alt = fields.get("og:image:alt", config["ogp_image_alt"])
129132

133+
# Decide whether to add social media card images for each page.
134+
# Only do this as a fallback if the user hasn't given any configuration
135+
# to add other images.
136+
config_social = DEFAULT_SOCIAL_CONFIG.copy()
137+
social_card_user_options = app.config.ogp_social_cards or {}
138+
config_social.update(social_card_user_options)
139+
140+
# This will only be False if the user explicitly sets it
141+
if (
142+
not (image_url or ogp_use_first_image)
143+
and config_social.get("enable") is not False
144+
):
145+
# Description
146+
description_max_length = config_social.get(
147+
"description_max_length", DEFAULT_DESCRIPTION_LENGTH_SOCIAL_CARDS - 3
148+
)
149+
if len(description) > description_max_length:
150+
description = description[:description_max_length].strip() + "..."
151+
152+
# Page title
153+
pagetitle = title
154+
if len(pagetitle) > DEFAULT_PAGE_LENGTH_SOCIAL_CARDS:
155+
pagetitle = pagetitle[:DEFAULT_PAGE_LENGTH_SOCIAL_CARDS] + "..."
156+
157+
# Site URL
158+
site_url = config_social.get("site_url", True)
159+
if site_url is True:
160+
url_text = app.config.ogp_site_url.split("://")[-1]
161+
elif isinstance(site_url, str):
162+
url_text = site_url
163+
164+
# Plot an image with the given metadata to the output path
165+
image_path = create_social_card(
166+
app,
167+
config_social,
168+
site_name,
169+
pagetitle,
170+
description,
171+
url_text,
172+
context["pagename"],
173+
)
174+
ogp_use_first_image = False
175+
176+
# Alt text is taken from description unless given
177+
if "og:image:alt" in fields:
178+
ogp_image_alt = fields.get("og:image:alt")
179+
else:
180+
ogp_image_alt = description
181+
182+
# Link the image in our page metadata
183+
# We use os.path.sep to standardize behavior acros *nix and Windows
184+
url = app.config.ogp_site_url.strip("/")
185+
image_path = str(image_path).replace(os.path.sep, "/").strip("/")
186+
image_url = f"{url}/{image_path}"
187+
130188
fields.pop("og:image:alt", None)
131189

132190
first_image = None
133191
if ogp_use_first_image:
192+
# Use the first image that is defined in the current page
134193
first_image = doctree.next_node(nodes.image)
135194
if (
136195
first_image
@@ -165,6 +224,12 @@ def get_tags(
165224
elif ogp_image_alt is None and title:
166225
tags["og:image:alt"] = title
167226

227+
if "ogp_social_card_tags" in context:
228+
# Add social media metadata if we've activated preview cards
229+
tags["og:image:width"] = meta["width"]
230+
tags["og:image:height"] = meta["height"]
231+
meta_tags["twitter:card"] = "summary_large_image"
232+
168233
# arbitrary tags and overrides
169234
tags.update({k: v for k, v in fields.items() if k.startswith("og:")})
170235

@@ -199,9 +264,11 @@ def setup(app: Sphinx) -> Dict[str, Any]:
199264
app.add_config_value("ogp_use_first_image", False, "html")
200265
app.add_config_value("ogp_type", "website", "html")
201266
app.add_config_value("ogp_site_name", None, "html")
267+
app.add_config_value("ogp_social_cards", None, "html")
202268
app.add_config_value("ogp_custom_meta_tags", [], "html")
203269
app.add_config_value("ogp_enable_meta_description", True, "html")
204270

271+
# Main Sphinx OpenGraph linking
205272
app.connect("html-page-context", html_page_context)
206273

207274
return {
1.67 MB
Binary file not shown.
Loading

0 commit comments

Comments
 (0)