| # Copyright (c) 2009, Giampaolo Rodola'. All rights reserved. |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| |
| """Insert a metadata banner (date + author + tags + reading time) |
| right below the H1 of each blog post. |
| |
| Ablog's sidebar exposes post metadata, but nothing appears near the |
| title on the post page itself. This extension walks post doctrees |
| and inserts a small container after the first section's title. |
| """ |
| |
| import math |
| |
| from ablog.blog import Blog |
| from docutils import nodes |
| |
| # Conservative words-per-minute estimate for technical prose. Code |
| # blocks are excluded from the word count. |
| WPM = 200 |
| SKIP_NODES = ( |
| nodes.literal_block, |
| nodes.doctest_block, |
| nodes.comment, |
| nodes.raw, |
| nodes.system_message, |
| ) |
| |
| |
| def count_words(node): |
| if isinstance(node, SKIP_NODES): |
| return 0 |
| if isinstance(node, nodes.Text): |
| return len(node.astext().split()) |
| return sum(count_words(c) for c in node.children) |
| |
| |
| def reading_minutes(doctree): |
| return max(1, math.ceil(count_words(doctree) / WPM)) |
| |
| |
| def tag_ref(app, blog, docname, label, text, classes=None): |
| """Return a nodes.reference from `docname` to the tag page for |
| `label`. |
| """ |
| coll = blog.tags[label] |
| # Let the builder compute this: dirhtml serves each page as a |
| # directory, so a hand-rolled relpath + ".html" lands a level off |
| # and points at a file that doesn't exist. |
| target = app.builder.get_relative_uri(docname, coll.docname) |
| return nodes.reference( |
| text, text, refuri=target, internal=True, classes=classes or [] |
| ) |
| |
| |
| def featured_inline(app, blog, post, docname): |
| tags = [str(t) for t in post.get("tags") or []] |
| if "featured" not in tags: |
| return None |
| # Reference must live inside a TextElement (sphinx html5 asserts |
| # this). Wrap in an inline like tags_inline does. |
| container = nodes.inline() |
| container += tag_ref( |
| app, blog, docname, "featured", "Featured", ["post-meta-featured"] |
| ) |
| return container |
| |
| |
| def author_inline(post): |
| authors = post.get("author") or [] |
| if not authors: |
| return None |
| text = ", ".join(str(a) for a in authors) |
| return nodes.inline(text, text, classes=["post-meta-author"]) |
| |
| |
| def date_inline(post): |
| date = post.get("date") |
| if not date: |
| return None |
| text = date.strftime("%b %d, %Y") |
| return nodes.inline(text, text, classes=["post-meta-date"]) |
| |
| |
| def readtime_inline(doctree): |
| text = f"{reading_minutes(doctree)} min read" |
| return nodes.inline(text, text, classes=["post-meta-readtime"]) |
| |
| |
| def tags_inline(app, blog, post, docname): |
| tags = [t for t in post.get("tags") or [] if str(t) != "featured"] |
| if not tags: |
| return None |
| container = nodes.inline(classes=["post-meta-tags"]) |
| for label in sorted(tags, key=str): |
| container += tag_ref(app, blog, docname, label, str(blog.tags[label])) |
| return container |
| |
| |
| def find_title_position(doctree): |
| """Return (section, title_index) for the first section with a |
| title, or (None, None) if none exists. |
| """ |
| section = next(iter(doctree.findall(nodes.section)), None) |
| if section is None: |
| return None, None |
| idx = next( |
| ( |
| i |
| for i, c in enumerate(section.children) |
| if isinstance(c, nodes.title) |
| ), |
| None, |
| ) |
| return (section, idx) if idx is not None else (None, None) |
| |
| |
| class post_banner(nodes.container, nodes.Invisible): |
| """Container subclass marked as docutils Invisible. The HTML |
| writer still renders it as a <div> (via the visit/depart |
| functions registered below), but sphinxext-opengraph's |
| description parser skips isinstance(node, nodes.Invisible) |
| nodes, so the banner text does not leak into og:description. |
| """ |
| |
| |
| def visit_post_banner(self, node): |
| self.visit_container(node) |
| |
| |
| def depart_post_banner(self, node): |
| self.depart_container(node) |
| |
| |
| def insert_banner(app, doctree, docname): |
| posts = getattr(app.env, "ablog_posts", {}).get(docname) |
| if not posts: |
| return |
| post = posts[0] |
| |
| section, title_idx = find_title_position(doctree) |
| if section is None: |
| return |
| |
| blog = Blog(app) |
| banner = post_banner(classes=["post-meta-banner"]) |
| for child in ( |
| featured_inline(app, blog, post, docname), |
| author_inline(post), |
| date_inline(post), |
| readtime_inline(doctree), |
| tags_inline(app, blog, post, docname), |
| ): |
| if child is not None: |
| banner += child |
| |
| section.insert(title_idx + 1, banner) |
| |
| |
| def setup(app): |
| app.add_node(post_banner, html=(visit_post_banner, depart_post_banner)) |
| app.connect("doctree-resolved", insert_banner) |
| return {"parallel_read_safe": True, "parallel_write_safe": True} |