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 '%s' % (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)