| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369 |
- from __future__ import annotations
- import os
- import posixpath
- from pathlib import Path
- from typing import TYPE_CHECKING
- from urllib.parse import urljoin, urlparse, urlsplit, urlunsplit
- from docutils import nodes
- from sphinxext.opengraph._description_parser import get_description
- from sphinxext.opengraph._meta_parser import get_meta_description
- from sphinxext.opengraph._title_parser import get_title
- try:
- from types import NoneType
- except ImportError:
- NoneType = type(None)
- if TYPE_CHECKING:
- from typing import Any
- from sphinx.application import Sphinx
- from sphinx.builders import Builder
- from sphinx.config import Config
- from sphinx.environment import BuildEnvironment
- from sphinx.util.typing import ExtensionMetadata
- try:
- from sphinxext.opengraph._social_cards import (
- DEFAULT_SOCIAL_CONFIG,
- create_social_card,
- )
- except ImportError:
- print('matplotlib is not installed, social cards will not be generated')
- create_social_card = None
- DEFAULT_SOCIAL_CONFIG = {}
- __version__ = '0.13.0'
- version_info = (0, 13, 0)
- DEFAULT_DESCRIPTION_LENGTH = 200
- DEFAULT_DESCRIPTION_LENGTH_SOCIAL_CARDS = 160
- DEFAULT_PAGE_LENGTH_SOCIAL_CARDS = 80
- # A selection from https://www.iana.org/assignments/media-types/media-types.xhtml#image
- IMAGE_MIME_TYPES = {
- 'gif': 'image/gif',
- 'apng': 'image/apng',
- 'webp': 'image/webp',
- 'jpeg': 'image/jpeg',
- 'jpg': 'image/jpeg',
- 'png': 'image/png',
- 'bmp': 'image/bmp',
- 'heic': 'image/heic',
- 'heif': 'image/heif',
- 'tiff': 'image/tiff',
- }
- def html_page_context(
- app: Sphinx,
- pagename: str,
- templatename: str,
- context: dict[str, Any],
- doctree: nodes.document,
- ) -> None:
- if app.builder.name == 'epub':
- return
- if doctree:
- context['metatags'] += get_tags(
- context,
- doctree,
- srcdir=app.srcdir,
- outdir=app.outdir,
- config=app.config,
- builder=app.builder,
- env=app.env,
- )
- def get_tags(
- context: dict[str, Any],
- doctree: nodes.document,
- *,
- srcdir: str | Path,
- outdir: str | Path,
- config: Config,
- builder: Builder,
- env: BuildEnvironment,
- ) -> str:
- # Get field lists for per-page overrides
- fields = context['meta']
- if fields is None:
- fields = {}
- if 'ogp_disable' in fields:
- return ''
- tags = {}
- meta_tags = {} # For non-og meta tags
- # Set length of description
- try:
- desc_len = int(
- fields.get('ogp_description_length', config.ogp_description_length)
- )
- except ValueError:
- desc_len = DEFAULT_DESCRIPTION_LENGTH
- # Get the title and parse any html in it
- title, title_excluding_html = get_title(context['title'])
- # Parse/walk doctree for metadata (tag/description)
- description = get_description(doctree, desc_len, {title, title_excluding_html})
- # title tag
- tags['og:title'] = title
- # type tag
- tags['og:type'] = config.ogp_type
- if not config.ogp_site_url and os.getenv('READTHEDOCS'):
- ogp_site_url = ambient_site_url()
- else:
- ogp_site_url = config.ogp_site_url
- # If ogp_canonical_url is not set, default to the value of ogp_site_url
- ogp_canonical_url = config.ogp_canonical_url or ogp_site_url
- # url tag
- # Get the URL of the specific page
- page_url = urljoin(ogp_canonical_url, builder.get_target_uri(context['pagename']))
- tags['og:url'] = page_url
- # site name tag, False disables, default to project if ogp_site_name not
- # set.
- if config.ogp_site_name is False:
- site_name = None
- elif config.ogp_site_name is None:
- site_name = config.project
- else:
- site_name = config.ogp_site_name
- if site_name:
- tags['og:site_name'] = site_name
- # description tag
- if description:
- tags['og:description'] = description
- if config.ogp_enable_meta_description and not get_meta_description(
- context['metatags']
- ):
- meta_tags['description'] = description
- # image tag
- # Get basic values from config
- if 'og:image' in fields:
- image_url = fields['og:image']
- ogp_use_first_image = False
- ogp_image_alt = fields.get('og:image:alt')
- fields.pop('og:image', None)
- else:
- image_url = config.ogp_image
- ogp_use_first_image = config.ogp_use_first_image
- ogp_image_alt = fields.get('og:image:alt', config.ogp_image_alt)
- # Decide whether to add social media card images for each page.
- # Only do this as a fallback if the user hasn't given any configuration
- # to add other images.
- config_social = DEFAULT_SOCIAL_CONFIG.copy()
- social_card_user_options = config.ogp_social_cards or {}
- config_social.update(social_card_user_options)
- if (
- not (image_url or ogp_use_first_image)
- and config_social.get('enable') is not False
- and create_social_card is not None
- ):
- image_url = social_card_for_page(
- config_social=config_social,
- site_name=site_name,
- title=title,
- description=description,
- pagename=context['pagename'],
- ogp_site_url=ogp_site_url,
- ogp_canonical_url=ogp_canonical_url,
- srcdir=srcdir,
- outdir=outdir,
- config=config,
- env=env,
- )
- ogp_use_first_image = False
- # Alt text is taken from description unless given
- if 'og:image:alt' in fields:
- ogp_image_alt = fields.get('og:image:alt')
- else:
- ogp_image_alt = description
- # If the social card objects have been added we add special metadata for them
- # These are the dimensions *in pixels* of the card
- # They were chosen by looking at the image pixel dimensions on disk
- tags['og:image:width'] = '1146'
- tags['og:image:height'] = '600'
- meta_tags['twitter:card'] = 'summary_large_image'
- fields.pop('og:image:alt', None)
- first_image = None
- if ogp_use_first_image:
- # Use the first image that is defined in the current page
- first_image = doctree.next_node(nodes.image)
- if (
- first_image
- and Path(first_image.get('uri', '')).suffix[1:].lower() in IMAGE_MIME_TYPES
- ):
- image_url = first_image['uri']
- ogp_image_alt = first_image.get('alt', None)
- else:
- first_image = None
- if image_url:
- # temporarily disable relative image paths with field lists
- if 'og:image' not in fields:
- image_url_parsed = urlparse(image_url)
- if not image_url_parsed.scheme:
- # Relative image path detected, relative to the source. Make absolute.
- if first_image: # NoQA: SIM108
- root = page_url
- else: # ogp_image is set
- # ogp_image is defined as being relative to the site root.
- # This workaround is to keep that functionality from breaking.
- root = ogp_site_url
- image_url = urljoin(root, image_url_parsed.path)
- tags['og:image'] = image_url
- # Add image alt text (either provided by config or from site_name)
- if isinstance(ogp_image_alt, str):
- tags['og:image:alt'] = ogp_image_alt
- elif ogp_image_alt is None and site_name:
- tags['og:image:alt'] = site_name
- elif ogp_image_alt is None and title:
- tags['og:image:alt'] = title
- # arbitrary tags and overrides
- tags.update({k: v for k, v in fields.items() if k.startswith('og:')})
- return (
- '\n'.join(
- [make_tag(p, c) for p, c in tags.items()]
- + [make_tag(p, c, 'name') for p, c in meta_tags.items()]
- + list(config.ogp_custom_meta_tags)
- )
- + '\n'
- )
- def ambient_site_url() -> str:
- # readthedocs addons sets the READTHEDOCS_CANONICAL_URL variable
- if rtd_canonical_url := os.getenv('READTHEDOCS_CANONICAL_URL'):
- parse_result = urlsplit(rtd_canonical_url)
- else:
- msg = 'ReadTheDocs did not provide a valid canonical URL!'
- raise RuntimeError(msg)
- # Grab root url from canonical url
- return urlunsplit(
- (parse_result.scheme, parse_result.netloc, parse_result.path, '', '')
- )
- def social_card_for_page(
- config_social: dict[str, bool | str],
- site_name: str,
- title: str,
- description: str,
- pagename: str,
- ogp_site_url: str,
- ogp_canonical_url: str,
- *,
- srcdir: str | Path,
- outdir: str | Path,
- config: Config,
- env: BuildEnvironment,
- ) -> str:
- # Description
- description_max_length = config_social.get(
- 'description_max_length', DEFAULT_DESCRIPTION_LENGTH_SOCIAL_CARDS - 3
- )
- if len(description) > description_max_length:
- description = description[:description_max_length].strip() + '...'
- # Page title
- pagetitle = title
- if len(pagetitle) > DEFAULT_PAGE_LENGTH_SOCIAL_CARDS:
- pagetitle = pagetitle[:DEFAULT_PAGE_LENGTH_SOCIAL_CARDS] + '...'
- # Site URL
- site_url = config_social.get('site_url', True)
- if site_url is True:
- url_text = ogp_canonical_url.split('://')[-1]
- elif isinstance(site_url, str):
- url_text = site_url
- # Plot an image with the given metadata to the output path
- image_path = create_social_card(
- config_social,
- site_name,
- pagetitle,
- description,
- url_text,
- pagename,
- srcdir=srcdir,
- outdir=outdir,
- env=env,
- html_logo=config.html_logo,
- )
- # Link the image in our page metadata
- return posixpath.join(ogp_site_url, image_path.as_posix())
- def make_tag(property: str, content: str, type_: str = 'property') -> str:
- # Parse quotation, so they won't break html tags if smart quotes are disabled
- content = content.replace('"', '"')
- return f'<meta {type_}="{property}" content="{content}" />'
- def setup(app: Sphinx) -> ExtensionMetadata:
- # ogp_site_url="" allows relative by default, even though it's not
- # officially supported by OGP.
- app.add_config_value('ogp_site_url', '', 'html', types=frozenset({str}))
- app.add_config_value('ogp_canonical_url', '', 'html', types=frozenset({str}))
- app.add_config_value(
- 'ogp_description_length',
- DEFAULT_DESCRIPTION_LENGTH,
- 'html',
- types=frozenset({int}),
- )
- app.add_config_value('ogp_image', None, 'html', types=frozenset({str, NoneType}))
- app.add_config_value(
- 'ogp_image_alt', None, 'html', types=frozenset({str, bool, NoneType})
- )
- app.add_config_value('ogp_use_first_image', False, 'html', types=frozenset({bool}))
- app.add_config_value('ogp_type', 'website', 'html', types=frozenset({str}))
- app.add_config_value(
- 'ogp_site_name', None, 'html', types=frozenset({str, bool, NoneType})
- )
- app.add_config_value(
- 'ogp_social_cards', None, 'html', types=frozenset({dict, NoneType})
- )
- app.add_config_value(
- 'ogp_custom_meta_tags', (), 'html', types=frozenset({list, tuple})
- )
- app.add_config_value(
- 'ogp_enable_meta_description', True, 'html', types=frozenset({bool})
- )
- # Main Sphinx OpenGraph linking
- app.connect('html-page-context', html_page_context)
- return {
- 'version': __version__,
- 'env_version': 1,
- 'parallel_read_safe': True,
- 'parallel_write_safe': True,
- }
|