Skip to content

Commit 8f89968

Browse files
committed
Added cards to show latest announcements in cards on the starting page.
1 parent bc6b620 commit 8f89968

5 files changed

Lines changed: 242 additions & 7 deletions

File tree

‎README.md‎

Lines changed: 59 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,63 @@ The [utPLSQL website](https://utplsql.github.io) is generated using [MkDocs](ht
55

66
## How to make an announcement post.
77

8-
- Create a new post file in the [docs/_posts](https://github.com/utPLSQL/utPLSQL.github.io/tree/main/docs/_posts) directory with the file name of `YYYY-MM-DD-Blog-Post-Name.md` This file will be a standard [Markdown file](https://github.com/adam-p/markdown-here/wiki/Markdown-Cheatsheet) which can be editing with any text editor although there are many offline and online editors for Markdown.
9-
- Add new entry pointing to new announcement file to the start of `nav` section in `mkdocs.yml`
10-
- Add new entry to the top of `index.md`
11-
- Commit and push changes to develop branch
8+
Announcements are published with the Material [blog plugin](https://squidfunk.github.io/mkdocs-material/plugins/blog/).
9+
10+
- Create a new post file in the [docs/announcements/posts](https://github.com/utPLSQL/utPLSQL.github.io/tree/main/docs/announcements/posts) directory, in the subfolder for the year (e.g. `2026/`), with the file name of `YYYY-MM-DD-Blog-Post-Name.md`. This file will be a standard [Markdown file](https://github.com/adam-p/markdown-here/wiki/Markdown-Cheatsheet) which can be edited with any text editor.
11+
- Start the file with front matter:
12+
```yaml
13+
---
14+
title: "utPLSQL v3.2.3 released"
15+
date:
16+
created: 2026-07-10
17+
categories:
18+
- "releases"
19+
- "utplsql-core"
20+
description: "Optional one-line summary shown on the home page card and in search results."
21+
pin: false
22+
---
23+
```
24+
- Put `<!-- more -->` after the first paragraph - everything above it is shown as the excerpt on the [Announcements](https://www.utplsql.org/announcements/index.html) page.
25+
- Commit and push changes to the `main` branch.
26+
27+
The post URL is built from the `title` (`announcements/<slugified-title>.html`), not from the file name or folder, so posts can be moved between folders without breaking links.
28+
Relative links inside a post (e.g. to images in `docs/assets`) are relative to the post file, so they need adjusting when a post is moved.
29+
30+
There is no need to update `mkdocs.yml` or `index.md` - the post appears on the Announcements page and on the home page automatically.
31+
32+
## Latest News on the home page
33+
34+
The "Latest News" cards on the home page are generated by a custom [MkDocs hook](https://www.mkdocs.org/user-guide/configuration/#hooks): [hooks/latest_posts.py](hooks/latest_posts.py).
35+
The Material blog plugin has no built-in way to list posts on other pages, so the hook fills that gap.
36+
37+
How it works:
38+
39+
- On every build, the hook replaces the `<!-- latest-posts -->` placeholder in [docs/index.md](docs/index.md) with [Material grid cards](https://squidfunk.github.io/mkdocs-material/reference/grids/#using-card-grids) for the most recent posts.
40+
- Posts come from the blog plugin (`config.plugins["material/blog"].blog.posts`), in the same order as on the Announcements page: pinned posts first, then newest first. Draft posts are skipped.
41+
- Each card shows an icon based on the post category, the title, the date and categories, a short summary and a "Read more" link. The whole card is clickable.
42+
- The summary is, in order of preference:
43+
1. the `description:` from the post front matter,
44+
2. the first paragraph before the first list - e.g. an intro sentence written above `## What's Changed` in the GitHub release notes,
45+
3. the list items of the release notes, joined with ` · ` and without author, PR and issue references (`by @user in #123`, `Fixed in ...`, `resolves ...`), e.g. `Fix issue with UT_TAP_REPORTER on Oracle 23.26 · Fixed support for camelCase ... · +4 more`.
46+
47+
Headings, `**Full Changelog**: ...` lines, link-only lines (e.g. `[Download ...](...)`) and bare URLs are never used. The summary is trimmed to about 180 characters.
48+
- Posts without a `description:` also get the generated summary as their `<meta name="description">`, used by search engines.
49+
- Release announcements generated by the release pipeline need no changes - the summary is built from the release notes automatically.
50+
51+
Controlling what is shown:
52+
53+
- To keep a post on the home page, add `pin: true` to its front matter. It also stays at the top of the Announcements page. Remove the flag when it should drop off.
54+
- To control the card text, add a `description:` to the post front matter.
55+
- The number of cards (`COUNT`), summary length (`SUMMARY_LENGTH`) and category icons (`ICONS`) are set at the top of [hooks/latest_posts.py](hooks/latest_posts.py). Icons use the [Material icon](https://squidfunk.github.io/mkdocs-material/reference/icons-emojis/) shortcode syntax.
56+
57+
Related configuration:
58+
59+
- `hooks:` in [mkdocs.yml](mkdocs.yml) registers the hook.
60+
- The `attr_list`, `md_in_html` and `pymdownx.emoji` Markdown extensions in [mkdocs.yml](mkdocs.yml) are required for the cards and icons.
61+
- The `.latest-posts` styles in [docs/stylesheets/extra.css](docs/stylesheets/extra.css) make the whole card clickable and style the date line and "Read more" link.
62+
63+
The hook is plain Python run by MkDocs itself - no extra package needs to be installed.
64+
Note that it relies on MkDocs hooks and on internals of the Material blog plugin, so it needs to be revisited when upgrading Material or migrating to [Zensical](https://zensical.org/).
1265

1366
## Local setup
1467

@@ -18,8 +71,9 @@ To install mkdocs required components, you need to execute the below commands fr
1871
```
1972
pip install mkdocs-material
2073
pip install mkdocs-git-revision-date-localized-plugin
74+
pip install mkdocs-include-markdown-plugin
75+
pip install mkdocs-git-committers-plugin-2
2176
pip install mike
22-
2377
```
2478

2579
Once installed you can use following commands from command line:

‎docs/index.md‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,13 @@ Free, open-source, and inspired by the xUnit family (JUnit, NUnit, etc.).
88

99
Write and run tests directly in PL/SQL, integrate with your CI/CD pipeline, and verify your code stability with every build.
1010

11-
!!! tip "Latest News"
12-
Stay up to date with releases and project updates on the [Announcements](announcements/index.md) page.
11+
---
12+
13+
## Latest News
14+
15+
<!-- latest-posts -->
16+
17+
[All announcements :octicons-arrow-right-24:](announcements/index.md)
1318

1419
---
1520

‎docs/stylesheets/extra.css‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,33 @@
1010
--md-accent-fg-color: #1f5db0;
1111
--md-accent-fg-color--transparent: #1f5db0;
1212
}
13+
14+
/* Latest news cards on the home page (generated by hooks/latest_posts.py) */
15+
.latest-posts > ul > li {
16+
position: relative;
17+
display: flex;
18+
flex-direction: column;
19+
}
20+
21+
/* Stretch the title link over the whole card */
22+
.latest-posts > ul > li strong a::after {
23+
content: "";
24+
position: absolute;
25+
inset: 0;
26+
}
27+
28+
.latest-posts__meta {
29+
display: block;
30+
font-size: .7rem;
31+
color: var(--md-default-fg-color--light);
32+
}
33+
34+
.latest-posts__more {
35+
margin-top: auto;
36+
font-size: .7rem;
37+
color: var(--md-accent-fg-color);
38+
}
39+
40+
[data-md-color-scheme="slate"] .latest-posts__more {
41+
color: var(--md-primary-fg-color);
42+
}

‎hooks/latest_posts.py‎

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
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

‎mkdocs.yml‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,9 +98,16 @@ markdown_extensions:
9898
- pymdownx.caret
9999
- pymdownx.mark
100100
- pymdownx.tilde
101+
- attr_list
102+
- md_in_html
103+
- pymdownx.emoji:
104+
emoji_index: !!python/name:material.extensions.emoji.twemoji
105+
emoji_generator: !!python/name:material.extensions.emoji.to_svg
101106
- toc:
102107
permalink: true
103108
use_directory_urls: false
109+
hooks:
110+
- hooks/latest_posts.py
104111
#strict: true
105112

106113
plugins:

0 commit comments

Comments
 (0)