|
1 | 1 | from __future__ import annotations
|
2 | 2 |
|
| 3 | +import dataclasses |
| 4 | +import functools |
| 5 | +import hashlib |
| 6 | +import logging |
3 | 7 | import os
|
4 | 8 | import posixpath
|
5 | 9 | from pathlib import Path
|
|
18 | 22 | NoneType = type(None)
|
19 | 23 |
|
20 | 24 | if TYPE_CHECKING:
|
21 |
| - from typing import Any |
| 25 | + import io |
| 26 | + from typing import Any, Callable |
22 | 27 |
|
23 | 28 | from sphinx.application import Sphinx
|
24 | 29 | from sphinx.builders import Builder
|
25 | 30 | from sphinx.config import Config
|
26 | 31 | from sphinx.environment import BuildEnvironment
|
27 | 32 | from sphinx.util.typing import ExtensionMetadata
|
28 | 33 |
|
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 = {} |
38 | 34 |
|
39 | 35 | __version__ = '0.13.0'
|
40 | 36 | version_info = (0, 13, 0)
|
41 | 37 |
|
| 38 | +LOGGER = logging.getLogger(__name__) |
42 | 39 | DEFAULT_DESCRIPTION_LENGTH = 200
|
43 |
| -DEFAULT_DESCRIPTION_LENGTH_SOCIAL_CARDS = 160 |
44 |
| -DEFAULT_PAGE_LENGTH_SOCIAL_CARDS = 80 |
| 40 | + |
45 | 41 |
|
46 | 42 | # A selection from https://www.iana.org/assignments/media-types/media-types.xhtml#image
|
47 | 43 | IMAGE_MIME_TYPES = {
|
|
58 | 54 | }
|
59 | 55 |
|
60 | 56 |
|
| 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 | + |
61 | 91 | def html_page_context(
|
62 | 92 | app: Sphinx,
|
63 | 93 | pagename: str,
|
@@ -137,7 +167,7 @@ def get_tags(
|
137 | 167 | # site name tag, False disables, default to project if ogp_site_name not
|
138 | 168 | # set.
|
139 | 169 | if config.ogp_site_name is False:
|
140 |
| - site_name = None |
| 170 | + site_name = '' |
141 | 171 | elif config.ogp_site_name is None:
|
142 | 172 | site_name = config.project
|
143 | 173 | else:
|
@@ -166,30 +196,25 @@ def get_tags(
|
166 | 196 | ogp_use_first_image = config.ogp_use_first_image
|
167 | 197 | ogp_image_alt = fields.get('og:image:alt', config.ogp_image_alt)
|
168 | 198 |
|
169 |
| - # Decide whether to add social media card images for each page. |
| 199 | + # Decide whether to generate a social media card image. |
170 | 200 | # 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, |
182 | 206 | site_name=site_name,
|
183 |
| - title=title, |
| 207 | + page_title=title, |
184 | 208 | 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, |
190 | 211 | config=config,
|
191 | 212 | env=env,
|
192 | 213 | )
|
| 214 | + |
| 215 | + if image_path: |
| 216 | + image_url = posixpath.join(ogp_site_url, image_path.as_posix()) |
| 217 | + |
193 | 218 | ogp_use_first_image = False
|
194 | 219 |
|
195 | 220 | # Alt text is taken from description unless given
|
@@ -271,55 +296,110 @@ def ambient_site_url() -> str:
|
271 | 296 | )
|
272 | 297 |
|
273 | 298 |
|
| 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 | + |
274 | 307 | def social_card_for_page(
|
275 |
| - config_social: dict[str, bool | str], |
| 308 | + *, |
| 309 | + app: Sphinx, |
276 | 310 | site_name: str,
|
277 |
| - title: str, |
| 311 | + page_title: str, |
278 | 312 | 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, |
285 | 314 | config: Config,
|
286 | 315 | 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, |
291 | 326 | )
|
292 |
| - if len(description) > description_max_length: |
293 |
| - description = description[:description_max_length].strip() + '...' |
294 | 327 |
|
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 |
299 | 330 |
|
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) |
306 | 332 |
|
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()) |
320 | 355 |
|
321 | 356 | # 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 | + ) |
323 | 403 |
|
324 | 404 |
|
325 | 405 | def make_tag(property: str, content: str, type_: str = 'property') -> str:
|
@@ -361,6 +441,17 @@ def setup(app: Sphinx) -> ExtensionMetadata:
|
361 | 441 | # Main Sphinx OpenGraph linking
|
362 | 442 | app.connect('html-page-context', html_page_context)
|
363 | 443 |
|
| 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 | + |
364 | 455 | return {
|
365 | 456 | 'version': __version__,
|
366 | 457 | 'env_version': 1,
|
|
0 commit comments