summaryrefslogtreecommitdiff
path: root/packages/blog-markdown-headings/lektor_blog_markdown_headings.py
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--packages/blog-markdown-headings/lektor_blog_markdown_headings.py152
1 files changed, 152 insertions, 0 deletions
diff --git a/packages/blog-markdown-headings/lektor_blog_markdown_headings.py b/packages/blog-markdown-headings/lektor_blog_markdown_headings.py
new file mode 100644
index 0000000..5293676
--- /dev/null
+++ b/packages/blog-markdown-headings/lektor_blog_markdown_headings.py
@@ -0,0 +1,152 @@
+import uuid
+from collections import namedtuple
+from html.parser import HTMLParser
+
+import mistune
+from lektor.pluginsystem import Plugin
+from lektor.utils import slugify
+from markupsafe import Markup, escape
+
+TocEntry = namedtuple("TocEntry", ["anchor", "title", "children"])
+
+
+def get_int_config(config, key, default, minimum=None, maximum=None):
+ value = config.get(key, str(default))
+
+ try:
+ value = int(value)
+ except (TypeError, ValueError):
+ value = default
+
+ if minimum is not None:
+ value = max(value, minimum)
+
+ if maximum is not None:
+ value = min(value, maximum)
+
+ return value
+
+
+class BlogMarkdownHeadingsPlugin(Plugin):
+ name = "Blog Markdown Headings"
+ description = (
+ "Adds Markdown heading anchors, TOC metadata, and configurable heading offsets."
+ )
+
+ def on_markdown_config(self, config, **extra):
+ plugin_config = self.get_config()
+
+ anchor_type = plugin_config.get("anchors.type", "slug")
+
+ heading_offset = get_int_config(
+ plugin_config,
+ "headings.offset",
+ default=2,
+ minimum=-5,
+ maximum=5,
+ )
+
+ max_heading_level = get_int_config(
+ plugin_config,
+ "headings.max_level",
+ default=6,
+ minimum=1,
+ maximum=6,
+ )
+
+ min_heading_level = get_int_config(
+ plugin_config,
+ "headings.min_level",
+ default=1,
+ minimum=1,
+ maximum=6,
+ )
+
+ def adjusted_level(level):
+ return min(
+ max(level + heading_offset, min_heading_level),
+ max_heading_level,
+ )
+
+ config.renderer_mixins.append(
+ renderer_mixin_factory(
+ anchor_type=anchor_type,
+ adjusted_level=adjusted_level,
+ )
+ )
+
+ def on_markdown_meta_init(self, meta, **extra):
+ meta["toc"] = []
+
+ def on_markdown_meta_postprocess(self, meta, **extra):
+ prev_level = None
+ toc = []
+ stack = [toc]
+
+ for level, anchor, title in meta["toc"]:
+ if prev_level is None:
+ prev_level = level
+ elif prev_level == level - 1:
+ stack.append(stack[-1][-1].children)
+ prev_level = level
+ elif prev_level > level:
+ while prev_level > level:
+ if len(stack) > 1:
+ stack.pop()
+ prev_level -= 1
+
+ stack[-1].append(TocEntry(anchor, title, []))
+
+ meta["toc"] = toc
+
+
+def renderer_mixin_factory(anchor_type, adjusted_level):
+ def render_heading(text, original_level, meta, raw=None):
+ level = adjusted_level(original_level)
+
+ if anchor_type == "random":
+ anchor = uuid.uuid4().hex[:6]
+ else:
+ if raw is None:
+ raw = escape(_strip_tags(text))
+ anchor = slugify(raw)
+
+ meta["toc"].append((level, anchor, Markup(text)))
+
+ return '<h%d id="%s">%s</h%d>' % (level, anchor, text, level)
+
+ class RendererMixinMistune0(object):
+ def header(self, text, level, raw):
+ return render_heading(text, level, self.meta, raw)
+
+ class RendererMixinMistune2(object):
+ def heading(self, text, level):
+ meta = self.lektor.meta
+ return render_heading(text, level, meta)
+
+ if mistune.__version__.startswith("0."):
+ return RendererMixinMistune0
+
+ if mistune.__version__.startswith("2."):
+ return RendererMixinMistune2
+
+ raise RuntimeError("Unsupported mistune version: %s" % mistune.__version__)
+
+
+def _strip_tags(inline_html):
+ parser = _StripTagsParser()
+ parser.feed(inline_html)
+ return parser.close()
+
+
+class _StripTagsParser(HTMLParser):
+ def reset(self):
+ super().reset()
+ self._data = []
+
+ def handle_data(self, data):
+ self._data.append(data)
+
+ def close(self):
+ super().close()
+ return "".join(self._data)