Skip to content

Commit efb37a0

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

File tree

3 files changed

+311
-205
lines changed

3 files changed

+311
-205
lines changed

docs/script/generate_social_card_previews.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@
1111
import random
1212
from pathlib import Path
1313

14-
from sphinxext.opengraph._social_cards import (
15-
MAX_CHAR_DESCRIPTION,
16-
MAX_CHAR_PAGE_TITLE,
14+
from sphinxext.opengraph._social_cards_matplotlib import (
15+
DEFAULT_DESCRIPTION_LENGTH,
16+
PAGE_TITLE_LENGTH,
1717
create_social_card_objects,
1818
render_social_card,
1919
)
@@ -40,11 +40,11 @@
4040
# Create dummy text description and pagetitle for this iteration
4141
random.shuffle(lorem)
4242
title = ' '.join(lorem[:100])
43-
title = title[: MAX_CHAR_PAGE_TITLE - 3] + '...'
43+
title = title[: PAGE_TITLE_LENGTH - 3] + '...'
4444

4545
random.shuffle(lorem)
4646
desc = ' '.join(lorem[:100])
47-
desc = desc[: MAX_CHAR_DESCRIPTION - 3] + '...'
47+
desc = desc[: DEFAULT_DESCRIPTION_LENGTH - 3] + '...'
4848

4949
path_tmp = Path(PROJECT_ROOT / 'docs/tmp')
5050
path_tmp.mkdir(exist_ok=True)
@@ -55,7 +55,7 @@
5555
site_title='Sphinx Social Card Demo',
5656
page_title=title,
5757
description=desc,
58-
siteurl='sphinxext-opengraph.readthedocs.io',
58+
site_url='sphinxext-opengraph.readthedocs.io',
5959
plt_objects=plt_objects,
6060
)
6161

sphinxext/opengraph/__init__.py

Lines changed: 160 additions & 69 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,40 @@
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+
env: BuildEnvironment
79+
html_logo: Path | None
80+
page_path: Path
81+
82+
@property
83+
def signature(self) -> str:
84+
"""A string that uniquely identifies the contents of this social card.
85+
86+
Used to avoid regenerating cards unnecessarily.
87+
"""
88+
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 ""}'
89+
90+
6191
def html_page_context(
6292
app: Sphinx,
6393
pagename: str,
@@ -137,7 +167,7 @@ def get_tags(
137167
# site name tag, False disables, default to project if ogp_site_name not
138168
# set.
139169
if config.ogp_site_name is False:
140-
site_name = None
170+
site_name = ''
141171
elif config.ogp_site_name is None:
142172
site_name = config.project
143173
else:
@@ -166,30 +196,25 @@ def get_tags(
166196
ogp_use_first_image = config.ogp_use_first_image
167197
ogp_image_alt = fields.get('og:image:alt', config.ogp_image_alt)
168198

169-
# Decide whether to add social media card images for each page.
199+
# Decide whether to generate a social media card image.
170200
# 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,
201+
# to add another image.
202+
203+
if not (image_url or ogp_use_first_image):
204+
image_path = social_card_for_page(
205+
app=builder.app,
182206
site_name=site_name,
183-
title=title,
207+
page_title=title,
184208
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,
209+
page_path=Path(context['pagename']),
210+
site_url=ogp_canonical_url,
190211
config=config,
191212
env=env,
192213
)
214+
215+
if image_path:
216+
image_url = posixpath.join(ogp_site_url, image_path.as_posix())
217+
193218
ogp_use_first_image = False
194219

195220
# Alt text is taken from description unless given
@@ -271,55 +296,110 @@ def ambient_site_url() -> str:
271296
)
272297

273298

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

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

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
331+
outdir = Path(app.outdir)
306332

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-
)
333+
# First callback to return a BytesIO object wins
334+
try:
335+
result = app.emit_firstresult(
336+
'generate-social-card',
337+
contents,
338+
functools.partial(check_if_signature_exists, outdir, page_path),
339+
allowed_exceptions=(CardAlreadyExistsError,),
340+
)
341+
except CardAlreadyExistsError as exc:
342+
return exc.path
343+
344+
if result is None:
345+
return None
346+
347+
image_bytes, signature = result
348+
349+
path_to_image = get_path_for_signature(page_path=page_path, signature=signature)
350+
351+
# Save the image to the output directory
352+
absolute_path = outdir / path_to_image
353+
absolute_path.parent.mkdir(exist_ok=True, parents=True)
354+
absolute_path.write_bytes(image_bytes.getbuffer())
320355

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

324404

325405
def make_tag(property: str, content: str, type_: str = 'property') -> str:
@@ -361,6 +441,17 @@ def setup(app: Sphinx) -> ExtensionMetadata:
361441
# Main Sphinx OpenGraph linking
362442
app.connect('html-page-context', html_page_context)
363443

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

0 commit comments

Comments
 (0)