Skip to content

Instantly share code, notes, and snippets.

@michaelkrupp
Created May 28, 2020 22:16
Show Gist options
  • Save michaelkrupp/564e077eef44a9239b4e7ba7693fef0e to your computer and use it in GitHub Desktop.
Save michaelkrupp/564e077eef44a9239b4e7ba7693fef0e to your computer and use it in GitHub Desktop.
from bs4 import BeautifulSoup
from distutils.util import strtobool
from glob import iglob
from markdown import Extension
from markdown.preprocessors import Preprocessor
import re
import codecs
import os
RE_ALL_SNIPPETS = re.compile(
r'''(?x)
^(?P<space>[ \t]*)
(?P<all>
(?:
(?P<block_marker>-{2,}8<-{2,})
(?P<options>
(?:
[ \t]+
[a-zA-Z][a-zA-Z0-9_]*=(?:(?P<quot>"|').*?(?P=quot))?
)*
)
(?![ \t])
)
)\r?$
'''
)
RE_SNIPPET = re.compile(
r'''(?x)
^(?P<space>[ \t]*)
(?P<snippet>.*?)\r?$
'''
)
RE_OPTIONS = re.compile(
r'''(?x)
(?P<key>[a-zA-Z][a-zA-Z0-9_]*)=(?:(?P<quot>"|')(?P<value>.*?)(?P=quot))?
'''
)
RE_INDENT = re.compile('^(\s+)', re.MULTILINE)
class SnippetPreprocessor(Preprocessor):
"""Handle snippets in Markdown content."""
def __init__(self, config, md):
"""Initialize."""
self._md = md
self.base_path = config.get('base_path')
self.encoding = config.get('encoding')
self.check_paths = config.get('check_paths')
self.tab_length = md.tab_length
super(SnippetPreprocessor, self).__init__()
def parse_options(self, string):
options = {}
if string is not None:
for m in RE_OPTIONS.finditer(string):
key = m.group('key')
value = m.group('value')
if value is None:
value = True
options[key] = value
return options
def parse_snippets(self, lines, file_name=None):
"""Parse snippets snippet."""
new_lines = []
block_lines = []
block = False
options = None
space = None
for line in lines:
m = RE_ALL_SNIPPETS.match(line)
if m:
block = not block
if block:
options = self.parse_options(m.group('options'))
else:
html = "\n".join(block_lines)
block_lines.clear()
if strtobool(options.get('postprocess', 'false')):
html = self._md.convert(html)
if strtobool(options.get('prettify', 'false')):
soup = BeautifulSoup(html, 'html.parser')
html = soup.prettify(formatter="html")
html = RE_INDENT.sub(lambda m: "\t" * len(m.group(1)), html)
new_lines.extend([space + l for l in html.splitlines()])
continue
elif not block:
new_lines.append(line)
continue
if block:
m = RE_SNIPPET.match(line)
if m:
space = m.group('space').expandtabs(self.tab_length)
path = m.group('snippet').strip()
# Block path handling
if not path:
# Empty path line, insert a blank line
block_lines.append('')
continue
for realpath in self.get_paths(path, options):
if realpath in self.seen:
# This is in the stack and we don't want an infinite loop!
continue
self.seen.add(realpath)
for line in self.render_snippet(realpath, space, options):
block_lines.append(line)
try:
self.seen.remove(realpath)
except KeyError:
pass # has already been removed in previous iteration
return new_lines
def render_snippet(self, filename, space, options):
with codecs.open(filename, 'r', encoding=self.encoding) as f:
if strtobool(options.get('recursive', 'true')):
return self.parse_snippets([l.rstrip('\r\n') for l in f], filename)
else:
return [l.rstrip('\r\n') for l in f]
def get_paths(self, path, options):
check = strtobool(options.get('check_paths', str(self.check_paths)))
basepath = os.path.realpath(self.base_path)
normpath = os.path.normpath(os.path.join(basepath, path))
realpaths = []
for realpath in sorted(iglob(normpath, recursive=True)):
if not realpath.startswith(basepath):
raise ValueError("Snippet at path %s is not located under %s" % (
path, self.base_path))
if not os.path.exists(realpath):
if check:
raise IOError("Snippet at path %s could not be found" % path)
continue
realpaths.append(realpath)
return realpaths
def run(self, lines):
"""Process snippets."""
self.seen = set()
return self.parse_snippets(lines)
class SnippetExtension(Extension):
"""Snippet extension."""
def __init__(self, *args, **kwargs):
"""Initialize."""
self.config = {
'base_path': [".", "Base path for snippet paths - Default: \"\""],
'encoding': ["utf-8", "Encoding of snippets - Default: \"utf-8\""],
'check_paths': [False, "Make the build fail if a snippet can't be found - Default: \"false\""]
}
super(SnippetExtension, self).__init__(*args, **kwargs)
def extendMarkdown(self, md):
"""Register the extension."""
self.md = md
md.registerExtension(self)
config = self.getConfigs()
snippet = SnippetPreprocessor(config, md)
md.preprocessors.register(snippet, "snippet", 32)
def makeExtension(*args, **kwargs):
"""Return extension."""
return SnippetExtension(*args, **kwargs)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment