|
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