summaryrefslogtreecommitdiff
path: root/packages/blog-markdown-headings/lektor_blog_markdown_headings.py
diff options
context:
space:
mode:
authorDennis Fink2026-06-18 17:57:51 +0200
committerDennis Fink2026-06-18 17:57:51 +0200
commita4e35942fee745e64db5d0099ba2582e0fb4417e (patch)
treef109c3e77805cdacd6f443c8a2d47514be5254a2 /packages/blog-markdown-headings/lektor_blog_markdown_headings.py
parente0904e48a5c1c5b327c29218fb39bf2fa05f1871 (diff)
downloaddennisfink.me-a4e35942fee745e64db5d0099ba2582e0fb4417e.tar.gz
dennisfink.me-a4e35942fee745e64db5d0099ba2582e0fb4417e.zip
Build the first usable version of the blog
This adds the Lektor project structure, models, templates, styling, feeds, categories, tags, archives, custom Markdown heading handling, and the initial content needed to make the blog usable.
Diffstat (limited to 'packages/blog-markdown-headings/lektor_blog_markdown_headings.py')
-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)