|
| 1 | +""" |
| 2 | +Renders the most recent announcements as Material grid cards. |
| 3 | +
|
| 4 | +Replaces the `<!-- latest-posts -->` placeholder in any page with cards for |
| 5 | +pinned posts (`pin: true`) followed by the newest ones, as ordered by the |
| 6 | +blog plugin. |
| 7 | +
|
| 8 | +Card text is, in order of preference: |
| 9 | + 1. the post's `description:` front matter, |
| 10 | + 2. the first paragraph before the first list (e.g. an intro above `## What's Changed`), |
| 11 | + 3. the list items (release notes), without author, PR and issue references. |
| 12 | +
|
| 13 | +Posts without a `description:` also get the generated summary as their |
| 14 | +`<meta name="description">`. |
| 15 | +""" |
| 16 | +import posixpath |
| 17 | +import re |
| 18 | + |
| 19 | +from material.plugins.blog.structure import Post |
| 20 | + |
| 21 | +PLACEHOLDER = "<!-- latest-posts -->" |
| 22 | +COUNT = 4 |
| 23 | +SUMMARY_LENGTH = 180 |
| 24 | + |
| 25 | +ICONS = { |
| 26 | + "releases": ":material-rocket-launch-outline:", |
| 27 | + "news": ":material-newspaper-variant-outline:", |
| 28 | + "website": ":material-web:", |
| 29 | + "tips-n-tricks": ":material-lightbulb-on-outline:", |
| 30 | +} |
| 31 | +DEFAULT_ICON = ":material-bullhorn-outline:" |
| 32 | + |
| 33 | +LINK = re.compile(r"\[([^\]]*)\]\([^)]*\)") |
| 34 | +LIST_ITEM = re.compile(r"^\s*[*+-]\s+(.*)$", re.MULTILINE) |
| 35 | +# Setext headings - a line underlined with --- or === |
| 36 | +SETEXT_HEADING = re.compile(r"^.+\n[-=]{3,}\s*$", re.MULTILINE) |
| 37 | +# Lines that are not prose: headings, lists, tables, HTML, code, rules, |
| 38 | +# "**Label**: ..." lines (e.g. Full Changelog), link-only lines (e.g. Download) and bare URLs |
| 39 | +NOT_PROSE = re.compile(r"^(#|<|!|\||`|[*+-] |---|\*\*[^*]+\*\*:|\[[^\]]*\]\([^)]*\)$|https?://\S+$)") |
| 40 | +# Trailing references of a release note item, e.g. " by @user in [#1](...)", |
| 41 | +# ". Fixed in [#1](...), resolves [#2](...)", " ([#1](...))", " #1" or ". See [documentation](...)" |
| 42 | +REFERENCES = re.compile( |
| 43 | + r"(\s+(by\s+\[?@\S+|\[?@\S+\s+in\s+\[#).*" |
| 44 | + r"|\.?\s+(Fixed|Implemented)\s+in\b.*" |
| 45 | + r"|[\s,]+-?\s*resolves\s+\[?#.*" |
| 46 | + r"|\.?\s+See\s+\[[^\]]*\]\([^)]*\)" |
| 47 | + r"|[\s,(]+\[#\d+\]\([^)]*\)\)?" |
| 48 | + r"|\s+#\d+)$", |
| 49 | + re.IGNORECASE, |
| 50 | +) |
| 51 | + |
| 52 | + |
| 53 | +def on_page_markdown(markdown, page, config, files): |
| 54 | + if PLACEHOLDER not in markdown: |
| 55 | + return markdown |
| 56 | + blog = config.plugins["material/blog"].blog |
| 57 | + posts = [post for post in blog.posts if not post.config.draft][:COUNT] |
| 58 | + base = posixpath.dirname(page.file.src_uri) |
| 59 | + cards = "\n".join(_card(post, posixpath.relpath(post.file.src_uri, base or ".")) for post in posts) |
| 60 | + html = f'<div class="grid cards latest-posts" markdown>\n\n{cards}\n</div>' |
| 61 | + return markdown.replace(PLACEHOLDER, html) |
| 62 | + |
| 63 | + |
| 64 | +# Runs after Markdown of all pages is processed, so the cards are not affected |
| 65 | +def on_page_context(context, page, config, nav): |
| 66 | + if isinstance(page, Post) and not page.meta.get("description"): |
| 67 | + summary = _summary(page) |
| 68 | + if summary: |
| 69 | + page.meta["description"] = re.sub(r"`|\*\*", "", summary) |
| 70 | + return context |
| 71 | + |
| 72 | + |
| 73 | +def _card(post, link): |
| 74 | + categories = post.config.categories |
| 75 | + icon = next((ICONS[c] for c in categories if c in ICONS), DEFAULT_ICON) |
| 76 | + created = post.config.date.created |
| 77 | + date = f"{created:%B} {created.day}, {created.year}" |
| 78 | + return ( |
| 79 | + f"- {icon}{{ .lg .middle }} __[{post.meta['title']}]({link})__\n\n" |
| 80 | + f" ---\n\n" |
| 81 | + f' <span class="latest-posts__meta">{date} · {" · ".join(categories)}</span>\n\n' |
| 82 | + f" {_summary(post)}\n\n" |
| 83 | + f' <span class="latest-posts__more">Read more :octicons-arrow-right-24:</span>\n' |
| 84 | + ) |
| 85 | + |
| 86 | + |
| 87 | +def _summary(post): |
| 88 | + if post.meta.get("description"): |
| 89 | + return _truncate(_plain(post.meta["description"])) |
| 90 | + body = SETEXT_HEADING.sub("", post.markdown) |
| 91 | + first_item = LIST_ITEM.search(body) |
| 92 | + intro = _first_paragraph(body[:first_item.start()] if first_item else body) |
| 93 | + if intro: |
| 94 | + return _truncate(_plain(intro)) |
| 95 | + return _changes(body) |
| 96 | + |
| 97 | + |
| 98 | +def _first_paragraph(markdown): |
| 99 | + # First run of prose lines |
| 100 | + lines = [] |
| 101 | + for line in markdown.splitlines(): |
| 102 | + line = line.strip() |
| 103 | + if line and not NOT_PROSE.match(line): |
| 104 | + lines.append(line) |
| 105 | + elif lines: |
| 106 | + break |
| 107 | + return " ".join(lines) |
| 108 | + |
| 109 | + |
| 110 | +def _changes(markdown): |
| 111 | + # Release note items joined into one line, e.g. "Fix A · Add B · +3 more" |
| 112 | + items = [_item(match.group(1)) for match in LIST_ITEM.finditer(markdown)] |
| 113 | + items = [item for item in items if item and not item.startswith("#")] |
| 114 | + summary = "" |
| 115 | + for count, item in enumerate(items): |
| 116 | + more = f" · +{len(items) - count} more" |
| 117 | + candidate = f"{summary} · {item}" if summary else item |
| 118 | + if summary and len(candidate) + len(more) > SUMMARY_LENGTH: |
| 119 | + return summary + more |
| 120 | + summary = candidate |
| 121 | + return _truncate(summary) |
| 122 | + |
| 123 | + |
| 124 | +def _item(text): |
| 125 | + previous = None |
| 126 | + while previous != text: |
| 127 | + previous, text = text, REFERENCES.sub("", text.strip()) |
| 128 | + return _plain(text).rstrip(" .,") |
| 129 | + |
| 130 | + |
| 131 | +def _plain(text): |
| 132 | + text = LINK.sub(r"\1", text) |
| 133 | + return re.sub(r"\s+", " ", text).strip() |
| 134 | + |
| 135 | + |
| 136 | +def _truncate(text): |
| 137 | + if len(text) > SUMMARY_LENGTH: |
| 138 | + text = text[:SUMMARY_LENGTH].rsplit(" ", 1)[0].rstrip(",.;:") + "…" |
| 139 | + return text |
0 commit comments