From a4e35942fee745e64db5d0099ba2582e0fb4417e Mon Sep 17 00:00:00 2001 From: Dennis Fink Date: Thu, 18 Jun 2026 17:57:51 +0200 Subject: 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. --- .../lektor_blog_markdown_headings.py | 152 +++++++++++++++++++++ packages/blog-markdown-headings/setup.py | 13 ++ 2 files changed, 165 insertions(+) create mode 100644 packages/blog-markdown-headings/lektor_blog_markdown_headings.py create mode 100644 packages/blog-markdown-headings/setup.py (limited to 'packages/blog-markdown-headings') 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 '%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) diff --git a/packages/blog-markdown-headings/setup.py b/packages/blog-markdown-headings/setup.py new file mode 100644 index 0000000..59f9895 --- /dev/null +++ b/packages/blog-markdown-headings/setup.py @@ -0,0 +1,13 @@ +from setuptools import setup + +setup( + name="lektor-blog-markdown-headings", + version="0.1", + py_modules=["lektor_blog_markdown_headings"], + entry_points={ + "lektor.plugins": [ + "blog-markdown-headings = lektor_blog_markdown_headings:BlogMarkdownHeadingsPlugin", + ] + }, + install_requires=[], +) -- cgit v1.3.1