1# vim: ts=8:sts=8:sw=8:noexpandtab
2
3# This file is part of python-markups module
4# License: 3-clause BSD, see LICENSE file
5# Copyright: (C) Dmitry Shachnev, 2012-2021
6
7import importlib
8import os
9import re
10import warnings
11import markups.common as common
12from markups.abstract import AbstractMarkup, ConvertedMarkup
13
14try:
15	import yaml
16except ImportError:
17	yaml = None
18
19MATHJAX2_CONFIG = \
20'''<script type="text/x-mathjax-config">
21MathJax.Hub.Config({
22  config: ["MMLorHTML.js"],
23  jax: ["input/TeX", "input/AsciiMath", "output/HTML-CSS", "output/NativeMML"],
24  extensions: ["MathMenu.js", "MathZoom.js"],
25  TeX: {
26    extensions: ["AMSmath.js", "AMSsymbols.js"],
27    equationNumbers: {autoNumber: "AMS"}
28  }
29});
30</script>
31'''
32
33# Taken from:
34# https://docs.mathjax.org/en/latest/upgrading/v2.html?highlight=upgrading#changes-in-the-mathjax-api
35MATHJAX3_CONFIG = \
36'''
37<script>
38MathJax = {
39  options: {
40    renderActions: {
41      find: [10, function (doc) {
42        for (const node of document.querySelectorAll('script[type^="math/tex"]')) {
43          const display = !!node.type.match(/; *mode=display/);
44          const math = new doc.options.MathItem(node.textContent, doc.inputJax[0], display);
45          const text = document.createTextNode('');
46          node.parentNode.replaceChild(text, node);
47          math.start = {node: text, delim: '', n: 0};
48          math.end = {node: text, delim: '', n: 0};
49          doc.math.push(math);
50        }
51      }, '']
52    }
53  }
54};
55</script>
56'''
57
58extensions_re = re.compile(r'required.extensions: (.+)', flags=re.IGNORECASE)
59extension_name_re = re.compile(r'[a-z0-9_.]+(?:\([^)]+\))?', flags=re.IGNORECASE)
60
61_canonicalized_ext_names = {}
62
63class MarkdownMarkup(AbstractMarkup):
64	"""Markup class for Markdown language.
65	Inherits :class:`~markups.abstract.AbstractMarkup`.
66
67	:param extensions: list of extension names
68	:type extensions: list
69	"""
70	name = 'Markdown'
71	attributes = {
72		common.LANGUAGE_HOME_PAGE: 'https://daringfireball.net/projects/markdown/',
73		common.MODULE_HOME_PAGE: 'https://github.com/Python-Markdown/markdown',
74		common.SYNTAX_DOCUMENTATION: 'https://daringfireball.net/projects/markdown/syntax'
75	}
76
77	file_extensions = ('.md', '.mkd', '.mkdn', '.mdwn', '.mdown', '.markdown')
78	default_extension = '.mkd'
79
80	@staticmethod
81	def available():
82		try:
83			import markdown
84		except ImportError:
85			return False
86		return (hasattr(markdown, '__version_info__') or  # underscored attribute means 3.x
87		        hasattr(markdown, 'version_info') and markdown.version_info >= (2, 6))
88
89	def _load_extensions_list_from_txt_file(self, filename):
90		with open(filename) as extensions_file:
91			for line in extensions_file:
92				if not line.startswith('#'):
93					yield self._split_extension_config(line.rstrip())
94
95	def _load_extensions_list_from_yaml_file(self, filename):
96		with open(filename) as extensions_file:
97			try:
98				data = yaml.safe_load(extensions_file)
99			except yaml.YAMLError as ex:
100				warnings.warn(f'Failed parsing {filename}: {ex}', SyntaxWarning)
101				raise IOError from ex
102		if isinstance(data, list):
103			for item in data:
104				if isinstance(item, dict):
105					yield from item.items()
106				elif isinstance(item, str):
107					yield item, {}
108
109	def _get_global_extensions(self, filename):
110		local_directory = os.path.dirname(filename) if filename else ''
111		choices = [
112			os.path.join(local_directory, 'markdown-extensions.yaml'),
113			os.path.join(local_directory, 'markdown-extensions.txt'),
114			os.path.join(common.CONFIGURATION_DIR, 'markdown-extensions.yaml'),
115			os.path.join(common.CONFIGURATION_DIR, 'markdown-extensions.txt'),
116		]
117		for choice in choices:
118			if choice.endswith('.yaml') and yaml is None:
119				continue
120			try:
121				if choice.endswith('.txt'):
122					yield from self._load_extensions_list_from_txt_file(choice)
123				else:
124					yield from self._load_extensions_list_from_yaml_file(choice)
125			except IOError:
126				continue  # Cannot open file, move to the next choice
127			else:
128				break  # File loaded successfully, skip the remaining choices
129
130	def _get_document_extensions(self, text):
131		lines = text.splitlines()
132		match = extensions_re.search(lines[0]) if lines else None
133		if match:
134			extensions = extension_name_re.findall(match.group(1))
135			yield from self._split_extensions_configs(extensions)
136
137	def _canonicalize_extension_name(self, extension_name):
138		prefixes = ('markdown.extensions.', '', 'mdx_')
139		for prefix in prefixes:
140			try:
141				module = importlib.import_module(prefix + extension_name)
142				if not hasattr(module, 'makeExtension'):
143					continue
144			except (ImportError, ValueError, TypeError):
145				pass
146			else:
147				return prefix + extension_name
148
149	def _split_extension_config(self, extension_name):
150		"""Splits the configuration options from the extension name."""
151		lb = extension_name.find('(')
152		if lb == -1:
153			return extension_name, {}
154		extension_name, parameters = extension_name[:lb], extension_name[lb + 1:-1]
155		pairs = [x.split("=") for x in parameters.split(",")]
156		return extension_name, {x.strip(): y.strip() for (x, y) in pairs}
157
158	def _split_extensions_configs(self, extensions):
159		"""Splits the configuration options from a list of strings.
160
161		:returns: a generator of (name, config) tuples
162		"""
163		for extension in extensions:
164			yield self._split_extension_config(extension)
165
166	def _apply_extensions(self, document_extensions=None):
167		extensions = self.global_extensions.copy()
168		extensions.extend(
169			self._split_extensions_configs(self.requested_extensions))
170		if document_extensions is not None:
171			extensions.extend(document_extensions)
172
173		extension_names = {"markdown.extensions.extra", "mdx_math"}
174		extension_configs = {}
175
176		for name, config in extensions:
177			if name == 'mathjax':
178				mathjax_config = {"enable_dollar_delimiter": True}
179				extension_configs["mdx_math"] = mathjax_config
180			elif name == 'remove_extra':
181				if "markdown.extensions.extra" in extension_names:
182					extension_names.remove("markdown.extensions.extra")
183				if "mdx_math" in extension_names:
184					extension_names.remove("mdx_math")
185			else:
186				if name in _canonicalized_ext_names:
187					canonical_name = _canonicalized_ext_names[name]
188				else:
189					canonical_name = self._canonicalize_extension_name(name)
190					if canonical_name is None:
191						warnings.warn('Extension "%s" does not exist.' %
192							name, ImportWarning)
193						continue
194					_canonicalized_ext_names[name] = canonical_name
195				extension_names.add(canonical_name)
196				extension_configs[canonical_name] = config
197		self.md = self.markdown.Markdown(extensions=list(extension_names),
198		                                 extension_configs=extension_configs,
199		                                 output_format='html5')
200		self.extensions = extension_names
201		self.extension_configs = extension_configs
202
203	def __init__(self, filename=None, extensions=None):
204		AbstractMarkup.__init__(self, filename)
205		import markdown
206		self.markdown = markdown
207		self.requested_extensions = extensions or []
208		self.global_extensions = []
209		if extensions is None:
210			self.global_extensions.extend(self._get_global_extensions(filename))
211		self._apply_extensions()
212
213	def convert(self, text):
214
215		# Determine body
216		self.md.reset()
217		self._apply_extensions(self._get_document_extensions(text))
218		body = self.md.convert(text) + '\n'
219
220		# Determine title
221		if hasattr(self.md, 'Meta') and 'title' in self.md.Meta:
222			title = str.join(' ', self.md.Meta['title'])
223		else:
224			title = ''
225
226		# Determine stylesheet
227		css_class = None
228
229		if 'markdown.extensions.codehilite' in self.extensions:
230			config = self.extension_configs.get('markdown.extensions.codehilite', {})
231			css_class = config.get('css_class', 'codehilite')
232			stylesheet = common.get_pygments_stylesheet('.%s' % css_class)
233		elif 'pymdownx.highlight' in self.extensions:
234			config = self.extension_configs.get('pymdownx.highlight', {})
235			css_class = config.get('css_class', 'highlight')
236			stylesheet = common.get_pygments_stylesheet('.%s' % css_class)
237		else:
238			stylesheet = ''
239
240		return ConvertedMarkdown(body, title, stylesheet)
241
242class ConvertedMarkdown(ConvertedMarkup):
243
244	def get_javascript(self, webenv=False):
245		if '<script type="math/' not in self.body:
246			return ''
247		mathjax_url, mathjax_version = common.get_mathjax_url_and_version(webenv)
248		config = MATHJAX3_CONFIG if mathjax_version == 3 else MATHJAX2_CONFIG
249		async_attr = ' async' if mathjax_version == 3 else ''
250		script_tag = '<script type="text/javascript" src="%s"%s></script>'
251		return config + script_tag % (mathjax_url, async_attr)
252