Skip to content

Commit 3c0d32c

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

File tree

3 files changed

+307
-205
lines changed

3 files changed

+307
-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: 156 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,24 @@ 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+
if image_path:
215+
posixpath.join(ogp_site_url, image_path.as_posix())
216+
193217
ogp_use_first_image = False
194218

195219
# Alt text is taken from description unless given
@@ -271,55 +295,107 @@ def ambient_site_url() -> str:
271295
)
272296

273297

298+
class CardAlreadyExists(Exception):
299+
def __init__(self, path: Path):
300+
self.path = path
301+
super().__init__(f'Card already exists: {path}')
302+
303+
274304
def social_card_for_page(
275-
config_social: dict[str, bool | str],
305+
*,
306+
app: Sphinx,
276307
site_name: str,
277-
title: str,
308+
page_title: str,
278309
description: str,
279-
pagename: str,
280-
ogp_site_url: str,
281-
ogp_canonical_url: str,
282-
*,
283-
srcdir: str | Path,
284-
outdir: str | Path,
310+
page_path: Path,
285311
config: Config,
286312
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+
env=env,
322+
html_logo=Path(config.html_logo) if config.html_logo else None,
291323
)
292-
if len(description) > description_max_length:
293-
description = description[:description_max_length].strip() + '...'
294324

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

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

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

321353
# Link the image in our page metadata
322-
return posixpath.join(ogp_site_url, image_path.as_posix())
354+
return path_to_image
355+
356+
357+
def hash_str(data: str) -> str:
358+
return hashlib.sha1(data.encode(), usedforsecurity=False).hexdigest()[:8]
359+
360+
361+
def get_path_for_signature(page_path: Path, signature: str) -> Path:
362+
"""Get a path for a social card image based on the page path and hash."""
363+
return (
364+
Path('_images')
365+
/ 'social_previews'
366+
/ f'summary_{str(page_path).replace("/", "_")}_{hash_str(signature)}.png'
367+
)
368+
369+
370+
def check_if_signature_exists(outdir: Path, page_path: Path, signature: str) -> None:
371+
"""Check if a file with the given hash already exists.
372+
373+
This is used to avoid regenerating social cards unnecessarily.
374+
"""
375+
path = outdir / get_path_for_signature(page_path=page_path, signature=signature)
376+
if path.exists():
377+
raise CardAlreadyExists(path=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+
f'matplotlib is not installed, social cards will not be generated: {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)