Skip to content

Commit 731c52f

Browse files
committed
Refactor social card to enable customizing generation
1 parent 95cf8a8 commit 731c52f

File tree

3 files changed

+323
-213
lines changed

3 files changed

+323
-213
lines changed

docs/script/generate_social_card_previews.py

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,14 @@
88

99
from __future__ import annotations
1010

11+
import io
1112
import random
1213
from pathlib import Path
1314

14-
from sphinxext.opengraph._social_cards import (
15-
MAX_CHAR_DESCRIPTION,
16-
MAX_CHAR_PAGE_TITLE,
15+
from sphinxext.opengraph._social_cards_matplotlib import (
16+
DEFAULT_DESCRIPTION_LENGTH,
17+
PAGE_TITLE_LENGTH,
18+
MatplotlibSocialCardSettings,
1719
create_social_card_objects,
1820
render_social_card,
1921
)
@@ -28,36 +30,42 @@
2830
cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non
2931
proident, sunt in culpa qui officia deserunt mollit anim id est laborum""".split() # NoQA: SIM905
3032

31-
kwargs_fig = {
32-
'image': PROJECT_ROOT / 'docs/_static/og-logo.png',
33-
'image_mini': PROJECT_ROOT / 'sphinxext/opengraph/_static/sphinx-logo-shadow.png',
34-
}
33+
settings = MatplotlibSocialCardSettings.from_values(
34+
{
35+
'image': PROJECT_ROOT / 'docs/_static/og-logo.png',
36+
'image_mini': PROJECT_ROOT
37+
/ 'sphinxext/opengraph/_static/sphinx-logo-shadow.png',
38+
}
39+
)
3540

3641
print('Generating previews of social media cards...')
37-
plt_objects = create_social_card_objects(**kwargs_fig)
42+
plt_objects = create_social_card_objects(settings)
3843
grid_items = []
3944
for perm in range(20):
4045
# Create dummy text description and pagetitle for this iteration
4146
random.shuffle(lorem)
4247
title = ' '.join(lorem[:100])
43-
title = title[: MAX_CHAR_PAGE_TITLE - 3] + '...'
48+
title = title[: PAGE_TITLE_LENGTH - 3] + '...'
4449

4550
random.shuffle(lorem)
4651
desc = ' '.join(lorem[:100])
47-
desc = desc[: MAX_CHAR_DESCRIPTION - 3] + '...'
52+
desc = desc[: DEFAULT_DESCRIPTION_LENGTH - 3] + '...'
4853

4954
path_tmp = Path(PROJECT_ROOT / 'docs/tmp')
5055
path_tmp.mkdir(exist_ok=True)
5156
path_out = Path(path_tmp / f'num_{perm}.png')
5257

58+
bytes_obj = io.BytesIO()
59+
5360
plt_objects = render_social_card(
54-
path=path_out,
61+
bytes_obj=bytes_obj,
5562
site_title='Sphinx Social Card Demo',
5663
page_title=title,
5764
description=desc,
58-
siteurl='sphinxext-opengraph.readthedocs.io',
65+
site_url='sphinxext-opengraph.readthedocs.io',
5966
plt_objects=plt_objects,
6067
)
68+
path_out.write_bytes(bytes_obj.getvalue())
6169

6270
path_examples_page_folder = PROJECT_ROOT / 'docs' / 'tmp'
6371
grid_items.append(f"""\

sphinxext/opengraph/__init__.py

Lines changed: 158 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
from __future__ import annotations
22

3+
import dataclasses
4+
import functools
5+
import hashlib
6+
import logging
37
import os
48
import posixpath
59
from pathlib import Path
@@ -18,30 +22,22 @@
1822
NoneType = type(None)
1923

2024
if TYPE_CHECKING:
21-
from typing import Any
25+
import io
26+
from typing import Any, Callable
2227

2328
from sphinx.application import Sphinx
2429
from sphinx.builders import Builder
2530
from sphinx.config import Config
2631
from sphinx.environment import BuildEnvironment
2732
from sphinx.util.typing import ExtensionMetadata
2833

29-
try:
30-
from sphinxext.opengraph._social_cards import (
31-
DEFAULT_SOCIAL_CONFIG,
32-
create_social_card,
33-
)
34-
except ImportError:
35-
print('matplotlib is not installed, social cards will not be generated')
36-
create_social_card = None
37-
DEFAULT_SOCIAL_CONFIG = {}
3834

3935
__version__ = '0.13.0'
4036
version_info = (0, 13, 0)
4137

38+
LOGGER = logging.getLogger(__name__)
4239
DEFAULT_DESCRIPTION_LENGTH = 200
43-
DEFAULT_DESCRIPTION_LENGTH_SOCIAL_CARDS = 160
44-
DEFAULT_PAGE_LENGTH_SOCIAL_CARDS = 80
40+
4541

4642
# A selection from https://www.iana.org/assignments/media-types/media-types.xhtml#image
4743
IMAGE_MIME_TYPES = {
@@ -58,6 +54,39 @@
5854
}
5955

6056

57+
@functools.cache
58+
def get_file_contents_hash(file_path: Path) -> str:
59+
"""Get a hash of the contents of a file."""
60+
hasher = hashlib.sha1(usedforsecurity=False)
61+
with file_path.open('rb') as f:
62+
while chunk := f.read(8192):
63+
hasher.update(chunk)
64+
return hasher.hexdigest()[:8]
65+
66+
67+
@dataclasses.dataclass
68+
class SocialCardContents:
69+
"""Parameters for generating a social card.
70+
71+
Received by the `generate-social-card` event.
72+
"""
73+
74+
site_name: str
75+
site_url: str
76+
page_title: str
77+
description: str
78+
html_logo: Path | None
79+
page_path: Path
80+
81+
@property
82+
def signature(self) -> str:
83+
"""A string that uniquely identifies the contents of this social card.
84+
85+
Used to avoid regenerating cards unnecessarily.
86+
"""
87+
return f'{self.site_name}{self.page_title}{self.description}{self.site_url}{get_file_contents_hash(self.html_logo) if self.html_logo else ""}'
88+
89+
6190
def html_page_context(
6291
app: Sphinx,
6392
pagename: str,
@@ -137,7 +166,7 @@ def get_tags(
137166
# site name tag, False disables, default to project if ogp_site_name not
138167
# set.
139168
if config.ogp_site_name is False:
140-
site_name = None
169+
site_name = ''
141170
elif config.ogp_site_name is None:
142171
site_name = config.project
143172
else:
@@ -166,30 +195,24 @@ def get_tags(
166195
ogp_use_first_image = config.ogp_use_first_image
167196
ogp_image_alt = fields.get('og:image:alt', config.ogp_image_alt)
168197

169-
# Decide whether to add social media card images for each page.
198+
# Decide whether to generate a social media card image.
170199
# Only do this as a fallback if the user hasn't given any configuration
171-
# to add other images.
172-
config_social = DEFAULT_SOCIAL_CONFIG.copy()
173-
social_card_user_options = config.ogp_social_cards or {}
174-
config_social.update(social_card_user_options)
175-
if (
176-
not (image_url or ogp_use_first_image)
177-
and config_social.get('enable') is not False
178-
and create_social_card is not None
179-
):
180-
image_url = social_card_for_page(
181-
config_social=config_social,
200+
# to add another image.
201+
202+
if not (image_url or ogp_use_first_image):
203+
image_path = social_card_for_page(
204+
app=builder.app,
182205
site_name=site_name,
183-
title=title,
206+
page_title=title,
184207
description=description,
185-
pagename=context['pagename'],
186-
ogp_site_url=ogp_site_url,
187-
ogp_canonical_url=ogp_canonical_url,
188-
srcdir=srcdir,
189-
outdir=outdir,
208+
page_path=Path(context['pagename']),
209+
site_url=ogp_canonical_url,
190210
config=config,
191-
env=env,
192211
)
212+
213+
if image_path:
214+
image_url = posixpath.join(ogp_site_url, image_path.as_posix())
215+
193216
ogp_use_first_image = False
194217

195218
# Alt text is taken from description unless given
@@ -271,55 +294,108 @@ def ambient_site_url() -> str:
271294
)
272295

273296

297+
class CardAlreadyExistsError(Exception):
298+
"""Raised when a social card already exists."""
299+
300+
def __init__(self, path: Path) -> None:
301+
self.path = path
302+
super().__init__(f'Card already exists: {path}')
303+
304+
274305
def social_card_for_page(
275-
config_social: dict[str, bool | str],
306+
*,
307+
app: Sphinx,
276308
site_name: str,
277-
title: str,
309+
page_title: str,
278310
description: str,
279-
pagename: str,
280-
ogp_site_url: str,
281-
ogp_canonical_url: str,
282-
*,
283-
srcdir: str | Path,
284-
outdir: str | Path,
311+
page_path: Path,
285312
config: Config,
286-
env: BuildEnvironment,
287-
) -> str:
288-
# Description
289-
description_max_length = config_social.get(
290-
'description_max_length', DEFAULT_DESCRIPTION_LENGTH_SOCIAL_CARDS - 3
313+
site_url: str,
314+
) -> Path | None:
315+
contents = SocialCardContents(
316+
site_name=site_name,
317+
site_url=site_url.split('://')[-1],
318+
page_title=page_title,
319+
description=description,
320+
page_path=page_path,
321+
html_logo=(app.srcdir / Path(config.html_logo)) if config.html_logo else None,
291322
)
292-
if len(description) > description_max_length:
293-
description = description[:description_max_length].strip() + '...'
294323

295-
# Page title
296-
pagetitle = title
297-
if len(pagetitle) > DEFAULT_PAGE_LENGTH_SOCIAL_CARDS:
298-
pagetitle = pagetitle[:DEFAULT_PAGE_LENGTH_SOCIAL_CARDS] + '...'
324+
image_bytes: io.BytesIO
325+
signature: str
299326

300-
# Site URL
301-
site_url = config_social.get('site_url', True)
302-
if site_url is True:
303-
url_text = ogp_canonical_url.split('://')[-1]
304-
elif isinstance(site_url, str):
305-
url_text = site_url
327+
outdir = Path(app.outdir)
306328

307-
# Plot an image with the given metadata to the output path
308-
image_path = create_social_card(
309-
config_social,
310-
site_name,
311-
pagetitle,
312-
description,
313-
url_text,
314-
pagename,
315-
srcdir=srcdir,
316-
outdir=outdir,
317-
env=env,
318-
html_logo=config.html_logo,
319-
)
329+
# First callback to return a BytesIO object wins
330+
try:
331+
result = app.emit_firstresult(
332+
'generate-social-card',
333+
contents,
334+
functools.partial(check_if_signature_exists, outdir, page_path),
335+
allowed_exceptions=(CardAlreadyExistsError,),
336+
)
337+
except CardAlreadyExistsError as exc:
338+
return exc.path
339+
340+
if result is None:
341+
return None
342+
343+
image_bytes, signature = result
344+
345+
path_to_image = get_path_for_signature(page_path=page_path, signature=signature)
346+
347+
# Save the image to the output directory
348+
absolute_path = outdir / path_to_image
349+
absolute_path.parent.mkdir(exist_ok=True, parents=True)
350+
absolute_path.write_bytes(image_bytes.getbuffer())
320351

321352
# Link the image in our page metadata
322-
return posixpath.join(ogp_site_url, image_path.as_posix())
353+
return path_to_image
354+
355+
356+
def hash_str(data: str) -> str:
357+
return hashlib.sha1(data.encode(), usedforsecurity=False).hexdigest()[:8]
358+
359+
360+
def get_path_for_signature(page_path: Path, signature: str) -> Path:
361+
"""Get a path for a social card image based on the page path and hash."""
362+
return (
363+
Path('_images')
364+
/ 'social_previews'
365+
/ f'summary_{str(page_path).replace("/", "_")}_{hash_str(signature)}.png'
366+
)
367+
368+
369+
def check_if_signature_exists(outdir: Path, page_path: Path, signature: str) -> None:
370+
"""Check if a file with the given hash already exists.
371+
372+
This is used to avoid regenerating social cards unnecessarily.
373+
"""
374+
relative_path = get_path_for_signature(page_path=page_path, signature=signature)
375+
path = outdir / relative_path
376+
if path.exists():
377+
raise CardAlreadyExistsError(path=relative_path)
378+
379+
380+
def create_social_card_matplotlib_fallback(
381+
app: Sphinx,
382+
contents: SocialCardContents,
383+
check_if_signature_exists: Callable[[str], None],
384+
) -> None | tuple[io.BytesIO, str]:
385+
try:
386+
from sphinxext.opengraph._social_cards_matplotlib import create_social_card
387+
except ImportError as exc:
388+
# Ideally we should raise and let people who don't want the card explicitly
389+
# disable it, but this would be a breaking change.
390+
LOGGER.warning(
391+
'matplotlib is not installed, social cards will not be generated: %s', exc
392+
)
393+
return None
394+
395+
# Plot an image with the given metadata to the output path
396+
return create_social_card(
397+
app=app, contents=contents, check_if_signature_exists=check_if_signature_exists
398+
)
323399

324400

325401
def make_tag(property: str, content: str, type_: str = 'property') -> str:
@@ -361,6 +437,17 @@ def setup(app: Sphinx) -> ExtensionMetadata:
361437
# Main Sphinx OpenGraph linking
362438
app.connect('html-page-context', html_page_context)
363439

440+
# Register event for customizing social card generation
441+
app.add_event(name='generate-social-card')
442+
# Add our matplotlib fallback, but with a low priority so that other
443+
# extensions can override it.
444+
# (default priority is 500, functions with lower priority numbers are called first).
445+
app.connect(
446+
'generate-social-card',
447+
create_social_card_matplotlib_fallback,
448+
priority=1000,
449+
)
450+
364451
return {
365452
'version': __version__,
366453
'env_version': 1,

0 commit comments

Comments
 (0)