__init__.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369
  1. from __future__ import annotations
  2. import os
  3. import posixpath
  4. from pathlib import Path
  5. from typing import TYPE_CHECKING
  6. from urllib.parse import urljoin, urlparse, urlsplit, urlunsplit
  7. from docutils import nodes
  8. from sphinxext.opengraph._description_parser import get_description
  9. from sphinxext.opengraph._meta_parser import get_meta_description
  10. from sphinxext.opengraph._title_parser import get_title
  11. try:
  12. from types import NoneType
  13. except ImportError:
  14. NoneType = type(None)
  15. if TYPE_CHECKING:
  16. from typing import Any
  17. from sphinx.application import Sphinx
  18. from sphinx.builders import Builder
  19. from sphinx.config import Config
  20. from sphinx.environment import BuildEnvironment
  21. from sphinx.util.typing import ExtensionMetadata
  22. try:
  23. from sphinxext.opengraph._social_cards import (
  24. DEFAULT_SOCIAL_CONFIG,
  25. create_social_card,
  26. )
  27. except ImportError:
  28. print('matplotlib is not installed, social cards will not be generated')
  29. create_social_card = None
  30. DEFAULT_SOCIAL_CONFIG = {}
  31. __version__ = '0.13.0'
  32. version_info = (0, 13, 0)
  33. DEFAULT_DESCRIPTION_LENGTH = 200
  34. DEFAULT_DESCRIPTION_LENGTH_SOCIAL_CARDS = 160
  35. DEFAULT_PAGE_LENGTH_SOCIAL_CARDS = 80
  36. # A selection from https://www.iana.org/assignments/media-types/media-types.xhtml#image
  37. IMAGE_MIME_TYPES = {
  38. 'gif': 'image/gif',
  39. 'apng': 'image/apng',
  40. 'webp': 'image/webp',
  41. 'jpeg': 'image/jpeg',
  42. 'jpg': 'image/jpeg',
  43. 'png': 'image/png',
  44. 'bmp': 'image/bmp',
  45. 'heic': 'image/heic',
  46. 'heif': 'image/heif',
  47. 'tiff': 'image/tiff',
  48. }
  49. def html_page_context(
  50. app: Sphinx,
  51. pagename: str,
  52. templatename: str,
  53. context: dict[str, Any],
  54. doctree: nodes.document,
  55. ) -> None:
  56. if app.builder.name == 'epub':
  57. return
  58. if doctree:
  59. context['metatags'] += get_tags(
  60. context,
  61. doctree,
  62. srcdir=app.srcdir,
  63. outdir=app.outdir,
  64. config=app.config,
  65. builder=app.builder,
  66. env=app.env,
  67. )
  68. def get_tags(
  69. context: dict[str, Any],
  70. doctree: nodes.document,
  71. *,
  72. srcdir: str | Path,
  73. outdir: str | Path,
  74. config: Config,
  75. builder: Builder,
  76. env: BuildEnvironment,
  77. ) -> str:
  78. # Get field lists for per-page overrides
  79. fields = context['meta']
  80. if fields is None:
  81. fields = {}
  82. if 'ogp_disable' in fields:
  83. return ''
  84. tags = {}
  85. meta_tags = {} # For non-og meta tags
  86. # Set length of description
  87. try:
  88. desc_len = int(
  89. fields.get('ogp_description_length', config.ogp_description_length)
  90. )
  91. except ValueError:
  92. desc_len = DEFAULT_DESCRIPTION_LENGTH
  93. # Get the title and parse any html in it
  94. title, title_excluding_html = get_title(context['title'])
  95. # Parse/walk doctree for metadata (tag/description)
  96. description = get_description(doctree, desc_len, {title, title_excluding_html})
  97. # title tag
  98. tags['og:title'] = title
  99. # type tag
  100. tags['og:type'] = config.ogp_type
  101. if not config.ogp_site_url and os.getenv('READTHEDOCS'):
  102. ogp_site_url = ambient_site_url()
  103. else:
  104. ogp_site_url = config.ogp_site_url
  105. # If ogp_canonical_url is not set, default to the value of ogp_site_url
  106. ogp_canonical_url = config.ogp_canonical_url or ogp_site_url
  107. # url tag
  108. # Get the URL of the specific page
  109. page_url = urljoin(ogp_canonical_url, builder.get_target_uri(context['pagename']))
  110. tags['og:url'] = page_url
  111. # site name tag, False disables, default to project if ogp_site_name not
  112. # set.
  113. if config.ogp_site_name is False:
  114. site_name = None
  115. elif config.ogp_site_name is None:
  116. site_name = config.project
  117. else:
  118. site_name = config.ogp_site_name
  119. if site_name:
  120. tags['og:site_name'] = site_name
  121. # description tag
  122. if description:
  123. tags['og:description'] = description
  124. if config.ogp_enable_meta_description and not get_meta_description(
  125. context['metatags']
  126. ):
  127. meta_tags['description'] = description
  128. # image tag
  129. # Get basic values from config
  130. if 'og:image' in fields:
  131. image_url = fields['og:image']
  132. ogp_use_first_image = False
  133. ogp_image_alt = fields.get('og:image:alt')
  134. fields.pop('og:image', None)
  135. else:
  136. image_url = config.ogp_image
  137. ogp_use_first_image = config.ogp_use_first_image
  138. ogp_image_alt = fields.get('og:image:alt', config.ogp_image_alt)
  139. # Decide whether to add social media card images for each page.
  140. # Only do this as a fallback if the user hasn't given any configuration
  141. # to add other images.
  142. config_social = DEFAULT_SOCIAL_CONFIG.copy()
  143. social_card_user_options = config.ogp_social_cards or {}
  144. config_social.update(social_card_user_options)
  145. if (
  146. not (image_url or ogp_use_first_image)
  147. and config_social.get('enable') is not False
  148. and create_social_card is not None
  149. ):
  150. image_url = social_card_for_page(
  151. config_social=config_social,
  152. site_name=site_name,
  153. title=title,
  154. description=description,
  155. pagename=context['pagename'],
  156. ogp_site_url=ogp_site_url,
  157. ogp_canonical_url=ogp_canonical_url,
  158. srcdir=srcdir,
  159. outdir=outdir,
  160. config=config,
  161. env=env,
  162. )
  163. ogp_use_first_image = False
  164. # Alt text is taken from description unless given
  165. if 'og:image:alt' in fields:
  166. ogp_image_alt = fields.get('og:image:alt')
  167. else:
  168. ogp_image_alt = description
  169. # If the social card objects have been added we add special metadata for them
  170. # These are the dimensions *in pixels* of the card
  171. # They were chosen by looking at the image pixel dimensions on disk
  172. tags['og:image:width'] = '1146'
  173. tags['og:image:height'] = '600'
  174. meta_tags['twitter:card'] = 'summary_large_image'
  175. fields.pop('og:image:alt', None)
  176. first_image = None
  177. if ogp_use_first_image:
  178. # Use the first image that is defined in the current page
  179. first_image = doctree.next_node(nodes.image)
  180. if (
  181. first_image
  182. and Path(first_image.get('uri', '')).suffix[1:].lower() in IMAGE_MIME_TYPES
  183. ):
  184. image_url = first_image['uri']
  185. ogp_image_alt = first_image.get('alt', None)
  186. else:
  187. first_image = None
  188. if image_url:
  189. # temporarily disable relative image paths with field lists
  190. if 'og:image' not in fields:
  191. image_url_parsed = urlparse(image_url)
  192. if not image_url_parsed.scheme:
  193. # Relative image path detected, relative to the source. Make absolute.
  194. if first_image: # NoQA: SIM108
  195. root = page_url
  196. else: # ogp_image is set
  197. # ogp_image is defined as being relative to the site root.
  198. # This workaround is to keep that functionality from breaking.
  199. root = ogp_site_url
  200. image_url = urljoin(root, image_url_parsed.path)
  201. tags['og:image'] = image_url
  202. # Add image alt text (either provided by config or from site_name)
  203. if isinstance(ogp_image_alt, str):
  204. tags['og:image:alt'] = ogp_image_alt
  205. elif ogp_image_alt is None and site_name:
  206. tags['og:image:alt'] = site_name
  207. elif ogp_image_alt is None and title:
  208. tags['og:image:alt'] = title
  209. # arbitrary tags and overrides
  210. tags.update({k: v for k, v in fields.items() if k.startswith('og:')})
  211. return (
  212. '\n'.join(
  213. [make_tag(p, c) for p, c in tags.items()]
  214. + [make_tag(p, c, 'name') for p, c in meta_tags.items()]
  215. + list(config.ogp_custom_meta_tags)
  216. )
  217. + '\n'
  218. )
  219. def ambient_site_url() -> str:
  220. # readthedocs addons sets the READTHEDOCS_CANONICAL_URL variable
  221. if rtd_canonical_url := os.getenv('READTHEDOCS_CANONICAL_URL'):
  222. parse_result = urlsplit(rtd_canonical_url)
  223. else:
  224. msg = 'ReadTheDocs did not provide a valid canonical URL!'
  225. raise RuntimeError(msg)
  226. # Grab root url from canonical url
  227. return urlunsplit(
  228. (parse_result.scheme, parse_result.netloc, parse_result.path, '', '')
  229. )
  230. def social_card_for_page(
  231. config_social: dict[str, bool | str],
  232. site_name: str,
  233. title: str,
  234. description: str,
  235. pagename: str,
  236. ogp_site_url: str,
  237. ogp_canonical_url: str,
  238. *,
  239. srcdir: str | Path,
  240. outdir: str | Path,
  241. config: Config,
  242. env: BuildEnvironment,
  243. ) -> str:
  244. # Description
  245. description_max_length = config_social.get(
  246. 'description_max_length', DEFAULT_DESCRIPTION_LENGTH_SOCIAL_CARDS - 3
  247. )
  248. if len(description) > description_max_length:
  249. description = description[:description_max_length].strip() + '...'
  250. # Page title
  251. pagetitle = title
  252. if len(pagetitle) > DEFAULT_PAGE_LENGTH_SOCIAL_CARDS:
  253. pagetitle = pagetitle[:DEFAULT_PAGE_LENGTH_SOCIAL_CARDS] + '...'
  254. # Site URL
  255. site_url = config_social.get('site_url', True)
  256. if site_url is True:
  257. url_text = ogp_canonical_url.split('://')[-1]
  258. elif isinstance(site_url, str):
  259. url_text = site_url
  260. # Plot an image with the given metadata to the output path
  261. image_path = create_social_card(
  262. config_social,
  263. site_name,
  264. pagetitle,
  265. description,
  266. url_text,
  267. pagename,
  268. srcdir=srcdir,
  269. outdir=outdir,
  270. env=env,
  271. html_logo=config.html_logo,
  272. )
  273. # Link the image in our page metadata
  274. return posixpath.join(ogp_site_url, image_path.as_posix())
  275. def make_tag(property: str, content: str, type_: str = 'property') -> str:
  276. # Parse quotation, so they won't break html tags if smart quotes are disabled
  277. content = content.replace('"', '"')
  278. return f'<meta {type_}="{property}" content="{content}" />'
  279. def setup(app: Sphinx) -> ExtensionMetadata:
  280. # ogp_site_url="" allows relative by default, even though it's not
  281. # officially supported by OGP.
  282. app.add_config_value('ogp_site_url', '', 'html', types=frozenset({str}))
  283. app.add_config_value('ogp_canonical_url', '', 'html', types=frozenset({str}))
  284. app.add_config_value(
  285. 'ogp_description_length',
  286. DEFAULT_DESCRIPTION_LENGTH,
  287. 'html',
  288. types=frozenset({int}),
  289. )
  290. app.add_config_value('ogp_image', None, 'html', types=frozenset({str, NoneType}))
  291. app.add_config_value(
  292. 'ogp_image_alt', None, 'html', types=frozenset({str, bool, NoneType})
  293. )
  294. app.add_config_value('ogp_use_first_image', False, 'html', types=frozenset({bool}))
  295. app.add_config_value('ogp_type', 'website', 'html', types=frozenset({str}))
  296. app.add_config_value(
  297. 'ogp_site_name', None, 'html', types=frozenset({str, bool, NoneType})
  298. )
  299. app.add_config_value(
  300. 'ogp_social_cards', None, 'html', types=frozenset({dict, NoneType})
  301. )
  302. app.add_config_value(
  303. 'ogp_custom_meta_tags', (), 'html', types=frozenset({list, tuple})
  304. )
  305. app.add_config_value(
  306. 'ogp_enable_meta_description', True, 'html', types=frozenset({bool})
  307. )
  308. # Main Sphinx OpenGraph linking
  309. app.connect('html-page-context', html_page_context)
  310. return {
  311. 'version': __version__,
  312. 'env_version': 1,
  313. 'parallel_read_safe': True,
  314. 'parallel_write_safe': True,
  315. }