From 077de96f7b1cd08b6f627a5dfbf776b6100a0cc1 Mon Sep 17 00:00:00 2001 From: Negin Sobhani Date: Thu, 20 Aug 2026 18:52:26 -0600 Subject: [PATCH 1/4] feat(docs): add Developers Blog announcement banner Add a site-wide announcement banner introducing the Earth2Studio Developers Blog, linking "Blog" to the blog tab and pointing readers to the how-to-contribute post. Underline the banner links while keeping them white to match the announcement bar. --- docs/assets/stylesheets/nvidia-material.css | 5 +++++ docs/overrides/partials/announce.html | 6 ++++++ 2 files changed, 11 insertions(+) diff --git a/docs/assets/stylesheets/nvidia-material.css b/docs/assets/stylesheets/nvidia-material.css index f0d9b2bc0..9ce9e20c7 100644 --- a/docs/assets/stylesheets/nvidia-material.css +++ b/docs/assets/stylesheets/nvidia-material.css @@ -288,6 +288,11 @@ body .md-grid { line-height: 1; } +.e2s-announcement a { + color: inherit !important; + text-decoration: underline; +} + .md-search__form { background-color: var(--e2s-header-search-bg) !important; border: 1px solid var(--e2s-header-search-border) !important; diff --git a/docs/overrides/partials/announce.html b/docs/overrides/partials/announce.html index 291235a6f..c04cc8dac 100644 --- a/docs/overrides/partials/announce.html +++ b/docs/overrides/partials/announce.html @@ -1,3 +1,9 @@ +
+ + + Introducing the Earth2Studio Developers Blog! Learn how to contribute a blog post. + +
From c034e2470917a7a53c826f627b26a96cf289b0bf Mon Sep 17 00:00:00 2001 From: Negin Sobhani Date: Thu, 20 Aug 2026 19:21:19 -0600 Subject: [PATCH 2/4] feat(docs): make announcement banners individually dismissible Add per-banner close buttons and a small script that remembers each dismissal in localStorage (keyed by a hash of the text so it resets when the message changes), hiding the whole bar once every banner is gone. Replaces the theme's announce.dismiss feature, which only handled a single announcement. --- docs/assets/javascripts/e2s-announce.js | 69 +++++++++++++++++++++ docs/assets/stylesheets/nvidia-material.css | 32 ++++++++++ docs/overrides/partials/announce.html | 10 ++- mkdocs.yml | 2 +- 4 files changed, 110 insertions(+), 3 deletions(-) create mode 100644 docs/assets/javascripts/e2s-announce.js diff --git a/docs/assets/javascripts/e2s-announce.js b/docs/assets/javascripts/e2s-announce.js new file mode 100644 index 000000000..264149971 --- /dev/null +++ b/docs/assets/javascripts/e2s-announce.js @@ -0,0 +1,69 @@ +(() => { + const STORAGE_PREFIX = "e2s-announce:"; + + // Small stable string hash so dismissals reset automatically when the + // announcement text changes (mirrors the theme's content-hash behaviour). + const hash = (value) => { + let h = 5381; + for (let i = 0; i < value.length; i += 1) { + h = ((h << 5) + h + value.charCodeAt(i)) | 0; + } + return (h >>> 0).toString(36); + }; + + const storageKey = (el) => { + const id = el.getAttribute("data-e2s-announce") || "announce"; + const text = (el.textContent || "").replace(/\s+/g, " ").trim(); + return `${STORAGE_PREFIX}${id}:${hash(text)}`; + }; + + const isDismissed = (key) => { + try { + return localStorage.getItem(key) === "1"; + } catch (err) { + return false; + } + }; + + const remember = (key) => { + try { + localStorage.setItem(key, "1"); + } catch (err) { + /* localStorage unavailable (private mode) โ€” dismiss for this view only */ + } + }; + + // Hide the whole announcement bar once every message inside it is gone, so + // we never leave an empty coloured banner behind. + const syncBar = () => { + const bar = document.querySelector("[data-md-component=announce]"); + if (!bar) return; + const items = bar.querySelectorAll(".e2s-announcement"); + const anyVisible = Array.prototype.some.call(items, (el) => !el.hidden); + bar.hidden = items.length > 0 && !anyVisible; + }; + + const init = () => { + const items = document.querySelectorAll(".e2s-announcement[data-e2s-announce]"); + items.forEach((el) => { + const key = storageKey(el); + if (isDismissed(key)) el.hidden = true; + + const button = el.querySelector(".e2s-announcement__close"); + if (button) { + button.addEventListener("click", () => { + el.hidden = true; + remember(key); + syncBar(); + }); + } + }); + syncBar(); + }; + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); + } else { + init(); + } +})(); diff --git a/docs/assets/stylesheets/nvidia-material.css b/docs/assets/stylesheets/nvidia-material.css index 9ce9e20c7..5039ac979 100644 --- a/docs/assets/stylesheets/nvidia-material.css +++ b/docs/assets/stylesheets/nvidia-material.css @@ -274,11 +274,13 @@ body .md-grid { } .e2s-announcement { + position: relative; display: flex; align-items: center; justify-content: center; gap: 0.55rem; min-height: 1.6rem; + padding: 0 1.8rem; font-size: 0.72rem; line-height: 1.35; text-align: center; @@ -293,6 +295,36 @@ body .md-grid { text-decoration: underline; } +.e2s-announcement__close { + position: absolute; + top: 50%; + right: 0.2rem; + transform: translateY(-50%); + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.2rem; + height: 1.2rem; + padding: 0; + border: 0; + border-radius: 0.2rem; + background: transparent; + color: inherit; + cursor: pointer; + opacity: 0.7; + transition: opacity 0.15s ease, background 0.15s ease; +} + +.e2s-announcement__close:hover { + opacity: 1; + background: rgba(255, 255, 255, 0.14); +} + +.e2s-announcement__close svg { + width: 0.85rem; + height: 0.85rem; +} + .md-search__form { background-color: var(--e2s-header-search-bg) !important; border: 1px solid var(--e2s-header-search-border) !important; diff --git a/docs/overrides/partials/announce.html b/docs/overrides/partials/announce.html index c04cc8dac..a832d88e6 100644 --- a/docs/overrides/partials/announce.html +++ b/docs/overrides/partials/announce.html @@ -1,13 +1,19 @@ -
+
Introducing the Earth2Studio Developers Blog! Learn how to contribute a blog post. +
-
+
Pardon the dust. We are working to actively improve the Earth2Studio docs page. Apologies for any inconvenience. +
diff --git a/mkdocs.yml b/mkdocs.yml index aab32340d..149aa444c 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -21,7 +21,6 @@ theme: text: NVIDIA Sans code: JetBrains Mono features: - - announce.dismiss - navigation.tabs - navigation.tabs.sticky - navigation.indexes @@ -54,6 +53,7 @@ extra_css: - assets/stylesheets/nvidia-material.css - assets/stylesheets/earth2studio-gallery.css extra_javascript: +- assets/javascripts/e2s-announce.js - assets/javascripts/e2s-landing.js - assets/javascripts/e2s-install-selector.js - assets/javascripts/e2s-catalog.js From 0fa465768bdfb6e5c8d5dbdd86ac667483e7f691 Mon Sep 17 00:00:00 2001 From: Negin Sobhani Date: Thu, 20 Aug 2026 19:56:43 -0600 Subject: [PATCH 3/4] feat(docs): add Earth2Studio Blog contribution guide and generated listings Add a "How to Contribute to the Earth2Studio Blog" post plus the supporting blog tooling for the Zensical-based docs, since Zensical has no blog plugin: - Generate the blog overview, archive, and category pages, per-post author bylines, and the RSS feed from post front matter and .authors.yml (generate_blog_index.py, generate_blog_rss.py, content.html override). - Add a Subscribe-via-RSS button on the overview, clickable category badges, and an RSS /social icon; scoped e2s-* styling. - Wire the generators into every docs build target (Makefile, docs/Makefile) and CI (build_docs_examples.sh). - Treat the generated listing/byline pages as build artifacts: git-ignore and untrack them. --- .gitignore | 6 + Makefile | 8 + docs/Makefile | 4 + docs/assets/stylesheets/nvidia-material.css | 110 +++++ docs/blog/.authors.yml | 7 + docs/blog/.categories.yml | 7 + docs/blog/archive.md | 5 - docs/blog/categories.md | 10 - docs/blog/categories/documentation.md | 6 - docs/blog/index.md | 15 - .../how-to-contribute-to-earth2studio-blog.md | 287 +++++++++++++ docs/generate_blog_index.py | 405 ++++++++++++++++++ docs/generate_blog_rss.py | 248 +++++++++++ docs/overrides/main.html | 5 + docs/overrides/partials/content.html | 23 + mkdocs.yml | 13 +- test/_ci/build_docs_examples.sh | 2 + 17 files changed, 1120 insertions(+), 41 deletions(-) create mode 100644 docs/blog/.authors.yml create mode 100644 docs/blog/.categories.yml delete mode 100644 docs/blog/archive.md delete mode 100644 docs/blog/categories.md delete mode 100644 docs/blog/categories/documentation.md delete mode 100644 docs/blog/index.md create mode 100644 docs/blog/posts/how-to-contribute-to-earth2studio-blog.md create mode 100644 docs/generate_blog_index.py create mode 100644 docs/generate_blog_rss.py create mode 100644 docs/overrides/partials/content.html diff --git a/.gitignore b/.gitignore index ffece433f..7f355985c 100644 --- a/.gitignore +++ b/.gitignore @@ -98,6 +98,12 @@ cufile.log site/ docs/assets/stylesheets/earth2studio-gallery.css docs/assets/data/ +docs/blog/feed.xml +docs/blog/index.md +docs/blog/archive.md +docs/blog/categories.md +docs/blog/categories/ +docs/overrides/partials/_blog_byline.html .e2sgallery/ _build docs/modules/generated diff --git a/Makefile b/Makefile index bb193eec5..155e63708 100644 --- a/Makefile +++ b/Makefile @@ -85,6 +85,8 @@ docs: uv run python docs/generate_api.py uv run python docs/generate_catalog.py uv run python docs/generate_install_options.py + uv run python docs/generate_blog_rss.py + uv run python docs/generate_blog_index.py uv run python docs/generate_gallery.py E2S_GALLERY_EXECUTE=never uv run zensical build --clean rm -rf site/__pycache__ site/_build/html @@ -105,6 +107,8 @@ docs-dev: uv run python docs/generate_api.py uv run python docs/generate_catalog.py uv run python docs/generate_install_options.py + uv run python docs/generate_blog_rss.py + uv run python docs/generate_blog_index.py @if [ -n "$(FILENAME)" ]; then \ uv run e2s-gallery build "$(FILENAME)" --execute stale --jobs $(DOCS_JOBS); \ fi @@ -118,6 +122,8 @@ docs-build-version: uv run python docs/generate_api.py uv run python docs/generate_catalog.py uv run python docs/generate_install_options.py + uv run python docs/generate_blog_rss.py + uv run python docs/generate_blog_index.py uv run python docs/generate_gallery.py DOC_VERSION=$(DOC_VERSION) E2S_GALLERY_EXECUTE=never uv run zensical build --clean rm -rf site/__pycache__ site/_build/html @@ -139,6 +145,8 @@ docs-serve: uv run python docs/generate_api.py uv run python docs/generate_catalog.py uv run python docs/generate_install_options.py + uv run python docs/generate_blog_rss.py + uv run python docs/generate_blog_index.py E2S_GALLERY_EXECUTE=never uv run zensical serve -a 0.0.0.0:$(PORT) .PHONY: container-service diff --git a/docs/Makefile b/docs/Makefile index 112f4896a..c52c3cb8d 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -13,6 +13,8 @@ help: html build: cd .. && python docs/generate_api.py cd .. && python docs/generate_install_options.py + cd .. && python docs/generate_blog_rss.py + cd .. && python docs/generate_blog_index.py rm -rf "$(BUILDDIR)" cd .. && E2S_GALLERY_EXECUTE=never $(ZENSICAL) build --clean mkdir -p _build @@ -21,6 +23,8 @@ html build: serve: cd .. && python docs/generate_api.py cd .. && python docs/generate_install_options.py + cd .. && python docs/generate_blog_rss.py + cd .. && python docs/generate_blog_index.py cd .. && E2S_GALLERY_EXECUTE=never $(ZENSICAL) serve -a 0.0.0.0:$(PORT) clean: diff --git a/docs/assets/stylesheets/nvidia-material.css b/docs/assets/stylesheets/nvidia-material.css index 5039ac979..2d6065dee 100644 --- a/docs/assets/stylesheets/nvidia-material.css +++ b/docs/assets/stylesheets/nvidia-material.css @@ -2886,3 +2886,113 @@ body .md-grid { stroke-linejoin: round; stroke-width: 2; } + +/* Clickable category badges in the blog overview listing. */ +.md-typeset a.e2s-cat-badge { + display: inline-block; + padding: 0.05rem 0.5rem; + border: 1px solid rgba(118, 185, 0, 0.4); + border-radius: 1rem; + font-size: 0.65rem; + font-weight: 600; + color: var(--nv-green-2, #76b900); + text-decoration: none; +} + +.md-typeset a.e2s-cat-badge:hover, +.md-typeset a.e2s-cat-badge:focus { + background: rgba(118, 185, 0, 0.12); +} + +/* Subscribe (RSS) button on the blog overview page. */ +.md-typeset .e2s-blog-subscribe { + display: inline-flex; + align-items: center; + gap: 0.45rem; + padding: 0.4rem 0.9rem; + border: 1px solid rgba(118, 185, 0, 0.5); + border-radius: 0.4rem; + background: rgba(118, 185, 0, 0.12); + color: var(--nv-green-2, #76b900); + font-weight: 600; + font-size: 0.72rem; + text-decoration: none; + transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease; +} + +.md-typeset .e2s-blog-subscribe:hover, +.md-typeset .e2s-blog-subscribe:focus { + background: var(--nv-green-2, #76b900); + border-color: var(--nv-green-2, #76b900); + color: #fff; +} + +.md-typeset .e2s-blog-subscribe .twemoji { + display: inline-flex; +} + +.md-typeset .e2s-blog-subscribe .twemoji svg { + width: 0.95rem; + height: 0.95rem; + fill: currentcolor; +} + +/* Blog post byline, rendered under the title by the content.html override. */ +.md-typeset .e2s-byline { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.4rem 0.9rem; + margin: -0.4rem 0 1.2rem; + font-size: 0.72rem; + color: var(--md-default-fg-color--light); +} + +.md-typeset .e2s-byline__authors { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; +} + +.md-typeset .e2s-byline__author { + display: inline-flex; + align-items: center; + gap: 0.4rem; + font-weight: 600; + color: var(--md-default-fg-color); +} + +.md-typeset .e2s-byline__author:hover { + color: var(--nv-green-2, #76b900); +} + +.md-typeset .e2s-byline__avatar { + width: 1.6rem; + height: 1.6rem; + border-radius: 50%; + object-fit: cover; +} + +.md-typeset .e2s-byline__date::before { + content: ""; +} + +.md-typeset .e2s-byline__cats { + display: inline-flex; + flex-wrap: wrap; + gap: 0.4rem; +} + +.md-typeset .e2s-byline__cat { + padding: 0.05rem 0.5rem; + border: 1px solid rgba(118, 185, 0, 0.4); + border-radius: 1rem; + font-size: 0.65rem; + font-weight: 600; + color: var(--nv-green-2, #76b900); +} + +.md-typeset .e2s-byline__cat:hover { + background: rgba(118, 185, 0, 0.12); +} diff --git a/docs/blog/.authors.yml b/docs/blog/.authors.yml new file mode 100644 index 000000000..a5fb3a52a --- /dev/null +++ b/docs/blog/.authors.yml @@ -0,0 +1,7 @@ +authors: + negin513: + name: Negin Sobhani + description: Earth2Studio + avatar: https://github.com/negin513.png + slug: negin513 + url: https://github.com/negin513 diff --git a/docs/blog/.categories.yml b/docs/blog/.categories.yml new file mode 100644 index 000000000..b490c0c11 --- /dev/null +++ b/docs/blog/.categories.yml @@ -0,0 +1,7 @@ +# Optional category descriptions for generated blog listing pages. +# Consumed by docs/generate_blog_index.py. Categories without an entry here +# fall back to a generic description. +Guide: How-to posts for contributing to Earth2Studio, including the blog. +Documentation: >- + Updates about the Earth2Studio documentation system, site structure, and + developer-facing content. diff --git a/docs/blog/archive.md b/docs/blog/archive.md deleted file mode 100644 index 258ac930b..000000000 --- a/docs/blog/archive.md +++ /dev/null @@ -1,5 +0,0 @@ -# Archive - -## 2026 - -- [MkDocs Material Migration](posts/mkdocs-upgrade.md) ยท August 6, 2026 diff --git a/docs/blog/categories.md b/docs/blog/categories.md deleted file mode 100644 index 9b1f3a8bc..000000000 --- a/docs/blog/categories.md +++ /dev/null @@ -1,10 +0,0 @@ -# Categories - -Browse posts by topic. - -## [Documentation](categories/documentation.md) - -Updates about the Earth2Studio documentation system, site structure, and -developer-facing content. - -[View posts](categories/documentation.md) diff --git a/docs/blog/categories/documentation.md b/docs/blog/categories/documentation.md deleted file mode 100644 index 565fa1e77..000000000 --- a/docs/blog/categories/documentation.md +++ /dev/null @@ -1,6 +0,0 @@ -# Documentation - -Posts about the Earth2Studio documentation system, site structure, and -developer-facing content. - -- [MkDocs Material Migration](../posts/mkdocs-upgrade.md) ยท August 6, 2026 diff --git a/docs/blog/index.md b/docs/blog/index.md deleted file mode 100644 index a057859bc..000000000 --- a/docs/blog/index.md +++ /dev/null @@ -1,15 +0,0 @@ -# Blog - -Earth-2 product and engineering blog posts and updates. - -## Latest Updates - -### [MkDocs Material Migration](posts/mkdocs-upgrade.md) - -`Documentation` ยท August 6, 2026 - -Earth2Studio documentation is being refreshed with a MkDocs Material site, -clearer install guidance, a stronger landing page, and room for targeted product -and engineering updates. - -[Read post](posts/mkdocs-upgrade.md) diff --git a/docs/blog/posts/how-to-contribute-to-earth2studio-blog.md b/docs/blog/posts/how-to-contribute-to-earth2studio-blog.md new file mode 100644 index 000000000..fb8b3dc6f --- /dev/null +++ b/docs/blog/posts/how-to-contribute-to-earth2studio-blog.md @@ -0,0 +1,287 @@ +--- +description: Learn how to write, preview, and publish a post on the Earth2Studio Blog +draft: false +date: 2026-08-20 +categories: + - Guide +authors: + - negin513 +--- + + + +# How to Contribute to the Earth2Studio Blog + +The Earth2Studio Blog is a place for the community to share what they are +building, learning, and discovering with Earth2Studio and across the broader +Earth-2 ecosystem. + +Posts can cover new features, real-world workflows, performance results, +technical deep dives, community use cases, tutorials, data sources, models, +and practical tips that help others get more from Earth2Studio. + +Contributions are welcome from users, developers, researchers, and partners. +If you have a useful workflow, lesson learned, benchmark, integration, or idea +to share, we would love to feature it. + +!!! tip "The Earth2Studio Blog welcomes contributions from the community!" + + + +## Write and publish a post + +### 1. Fork the repository + +If you have not already, fork +[NVIDIA/earth2studio](https://github.com/NVIDIA/earth2studio). + +Then clone your fork and create a branch from `main`: + +```bash +git clone https://github.com//earth2studio.git +cd earth2studio +git switch main +git switch -c blog/ +``` + +### 2. Create your post + +Create a Markdown file under `docs/blog/posts/` using a short, lowercase, +hyphenated filename: + +```text +docs/blog/posts/.md +``` + +For example: + +```text +docs/blog/posts/how-to-contribute-to-earth2studio-blog.md +``` + +The filename determines the published URL: + +```text +/blog/posts// +``` + +The publication date comes from the `date` field in the front matter, so you +do not need to include a date in the filename. + +The blog directory looks like: + +```text +. +โ”œโ”€ docs/ +โ”‚ โ””โ”€ blog/ +โ”‚ โ”œโ”€ posts/ +โ”‚ โ”‚ โ”œโ”€ mkdocs-upgrade.md +โ”‚ โ”‚ โ””โ”€ your-post-title.md +โ”‚ โ”œโ”€ .authors.yml +โ”‚ โ””โ”€ .categories.yml +โ””โ”€ mkdocs.yml +``` + +### 3. Add the front matter + +Start each post with YAML front matter followed by a single `#` heading for +the post title: + +```yaml +--- +description: +draft: false +date: YYYY-MM-DD +categories: + - + - +authors: + - +--- + +# +``` + +The fields are: + +* `description`: A short summary used in blog listings and the RSS feed. +* `draft`: Set to `true` while a post should remain unpublished. +* `date`: Publication date in `YYYY-MM-DD` format. +* `categories`: Topics associated with the post. Use an existing category, + such as `Documentation` or `Guide`, or add a new category to + `docs/blog/.categories.yml`. +* `authors`: Author identifiers defined in `docs/blog/.authors.yml`. + +See the +[Material for MkDocs blog metadata documentation](https://squidfunk.github.io/mkdocs-material/plugins/blog/#metadata) +for additional supported metadata. + +Do not add a `title` field to the front matter. The first Markdown `#` heading +is used as the post title. Defining both can also trigger the `MD025` +markdownlint rule. + +The author, publication date, and category metadata displayed below the title +are generated automatically from the front matter and +`docs/blog/.authors.yml`. + +### 4. Write the post + +Write the rest of the post in Markdown. + +Aim for content that is useful and reusable by the broader Earth2Studio +community. Good topics include: + +* New Earth2Studio features and integrations +* End-to-end workflows and applications +* Performance results and optimization techniques +* Models and data sources +* Tutorials and practical examples +* Lessons learned from real-world deployments +* Community projects built with or around Earth2Studio + +For Markdown syntax, see the +[Markdown Guide cheat sheet](https://www.markdownguide.org/cheat-sheet/). + +### 5. Add an excerpt + +Use `` to control where the preview shown in blog listings ends: + +```markdown +This introduction appears in the blog listing. + + + +The full post continues here. +``` + +A useful excerpt should quickly tell readers what the post covers and why it +matters. + +### 6. Add yourself as an author + +List each author by identifier in the post front matter: + +```yaml +authors: + - + - +``` + +Each identifier must have a corresponding entry in +`docs/blog/.authors.yml`. + +If you are a first-time contributor, add yourself using a unique identifier. +Your GitHub username is a good default: + +```yaml +authors: + negin513: + name: Negin Sobhani + description: Earth2Studio + avatar: https://github.com/negin513.png + slug: negin513 + url: https://github.com/negin513 +``` + +The `name`, `avatar`, and `url` fields are used to generate the author +information displayed with the post. + +### 7. Add or reuse a category + +Whenever possible, use an existing blog category. + +If your post introduces a new topic, add the category and an optional +description to: + +```text +docs/blog/.categories.yml +``` + +You do not need to create or update category pages manually. + +### 8. Preview the post locally + +From the root of the Earth2Studio repository, install the documentation +dependencies: + +```bash +uv sync --group docs +``` + +Start the documentation server: + +```bash +make docs-serve +``` + +Your post is available at: + +```text +http://127.0.0.1:8001/earth2studio/blog/posts// +``` + +Before submitting your post, confirm that: + +* The post renders correctly. +* The post appears on the blog overview. +* The title, author, date, and categories are correct. +* The excerpt ends in the right place. +* The post appears on the expected category page. +* Links, images, code blocks, and other Markdown elements render correctly. + +`make docs-dev` can also be used for local development, but it installs +additional package extras and builds the example gallery. For a blog-only +preview, `make docs-serve` is the lighter option. + +### 9. Do not edit generated blog pages + +The blog overview, archive, and category pages are generated from the metadata +in individual posts. + +You should not manually update: + +```text +docs/blog/index.md +docs/blog/archive.md +docs/blog/categories.md +docs/blog/categories/*.md +``` + +These generated files are git-ignored and should not be committed. + +When adding a post, the Markdown file and its metadata are the source of truth. + +### 10. Commit and open a pull request + +When the post is ready, commit your changes and push your branch: + +```bash +git add docs/blog/posts/.md +git add docs/blog/.authors.yml # If you added an author +git add docs/blog/.categories.yml # If you added a category + +git commit -s -m "docs: add blog post " +git push -u origin blog/ +``` + +The `-s` flag adds the Developer Certificate of Origin sign-off required for +Earth2Studio contributions. + +Open a pull request against `NVIDIA/earth2studio:main` and complete the pull +request template. + +For first-time contributors, a maintainer may need to approve or trigger CI. +Address any CI failures or review feedback as you would for other +Earth2Studio contributions. + +### 11. Publish + +Once the pull request is merged, the post will be included in the +Earth2Studio Blog with the next documentation deployment. + +Thanks for contributing! + +--- + +Have something useful to share? Open a pull request and contribute it to the +Earth2Studio community. We look forward to seeing what you build. diff --git a/docs/generate_blog_index.py b/docs/generate_blog_index.py new file mode 100644 index 000000000..007457806 --- /dev/null +++ b/docs/generate_blog_index.py @@ -0,0 +1,405 @@ +# SPDX-FileCopyrightText: Copyright (c) 2024-2026 NVIDIA CORPORATION & AFFILIATES. +# SPDX-FileCopyrightText: All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Generate blog listing pages from Earth2Studio blog posts. + +Zensical does not yet ship a blog plugin, so the blog index, archive, and +category pages are generated from post front matter as a pre-build step (the +same way ``generate_blog_rss.py`` generates the RSS feed). This removes the +need to hand-maintain listings whenever a post is added. + +Zensical also has no author byline on blog posts. The byline is rendered by a +theme override (``docs/overrides/partials/content.html``) that imports a small +Minijinja macro generated here from ``docs/blog/.authors.yml``, so authors stay +defined in exactly one place. + +Generated files (git-ignored): + +* ``docs/blog/index.md`` +* ``docs/blog/archive.md`` +* ``docs/blog/categories.md`` +* ``docs/blog/categories/.md`` +* ``docs/overrides/partials/_blog_byline.html`` +""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass +from datetime import date, datetime +from pathlib import Path + +import yaml + +DOCS_ROOT = Path(__file__).resolve().parent +BLOG_DIR = DOCS_ROOT / "blog" +POSTS_DIR = BLOG_DIR / "posts" +CATEGORIES_DIR = BLOG_DIR / "categories" +CATEGORY_META = BLOG_DIR / ".categories.yml" +AUTHORS_FILE = BLOG_DIR / ".authors.yml" +BYLINE_PARTIAL = DOCS_ROOT / "overrides" / "partials" / "_blog_byline.html" +EXCERPT_SPLIT = "" + +BLOG_INTRO = "Earth-2 Product and Engineering Blog Posts and Updates." +BLOG_SUBSCRIBE_BUTTON = ( + "[:fontawesome-solid-rss: Subscribe via RSS](feed.xml)" + "{ .e2s-blog-subscribe }" +) +DEFAULT_CATEGORY_DESCRIPTION = "Posts filed under this topic." +GENERATED_NOTICE = ( + "" +) + + +@dataclass(frozen=True) +class BlogPost: + """A published blog post used to build listing pages.""" + + title: str + description: str + date: date + slug: str + categories: tuple[str, ...] + + +def main() -> None: + """Generate the blog index, archive, and category pages.""" + posts = _load_posts() + category_meta = _load_category_meta() + + _write(BLOG_DIR / "index.md", _render_index(posts)) + _write(BLOG_DIR / "archive.md", _render_archive(posts)) + _write(BLOG_DIR / "categories.md", _render_categories(posts, category_meta)) + + CATEGORIES_DIR.mkdir(parents=True, exist_ok=True) + seen: set[str] = set() + for name in _categories(posts): + slug = _slugify(name) + seen.add(f"{slug}.md") + _write( + CATEGORIES_DIR / f"{slug}.md", + _render_category_page(name, posts, category_meta), + ) + _prune_categories(seen) + + _write(BYLINE_PARTIAL, _render_byline_partial(_load_authors())) + + +def _load_posts() -> list[BlogPost]: + """Return published posts newest-first.""" + posts: list[BlogPost] = [] + if not POSTS_DIR.is_dir(): + return posts + + for path in sorted(POSTS_DIR.glob("*.md")): + post = _parse_post(path) + if post is not None: + posts.append(post) + + posts.sort(key=lambda item: (item.date, item.slug), reverse=True) + return posts + + +def _load_category_meta() -> dict[str, str]: + """Return optional ``category name -> description`` overrides.""" + if not CATEGORY_META.is_file(): + return {} + try: + data = yaml.safe_load(CATEGORY_META.read_text(encoding="utf-8")) or {} + except yaml.YAMLError: + return {} + if not isinstance(data, dict): + return {} + return {str(key): str(value) for key, value in data.items()} + + +def _load_authors() -> dict[str, dict[str, str]]: + """Return ``author id -> {name, url, avatar}`` from ``.authors.yml``.""" + if not AUTHORS_FILE.is_file(): + return {} + try: + data = yaml.safe_load(AUTHORS_FILE.read_text(encoding="utf-8")) or {} + except yaml.YAMLError: + return {} + authors = data.get("authors") if isinstance(data, dict) else None + if not isinstance(authors, dict): + return {} + + result: dict[str, dict[str, str]] = {} + for author_id, info in authors.items(): + if not isinstance(info, dict): + continue + result[str(author_id)] = { + "name": str(info.get("name") or author_id), + "url": str(info.get("url") or ""), + "avatar": str(info.get("avatar") or ""), + } + return result + + +def _render_byline_partial(authors: dict[str, dict[str, str]]) -> str: + """Return a Minijinja macro that renders a blog post byline. + + Zensical has no blog plugin, so the author byline is rendered by a theme + override (``docs/overrides/partials/content.html``). That template imports + this generated macro, which bakes in the ``.authors.yml`` data so author + identifiers resolve to a name, link, and avatar. Keeping the data source in + ``.authors.yml`` means authors are defined in exactly one place. + """ + authors_literal = json.dumps(authors, ensure_ascii=False, sort_keys=True) + return f"""{{#- Generated by docs/generate_blog_index.py. Do not edit. -#}} +{{% macro e2s_byline(page) -%}} +{{%- set authors = {authors_literal} -%}} + +{{%- endmacro %}} +""" + + +def _parse_post(path: Path) -> BlogPost | None: + """Return a published post, or ``None`` for drafts and undated files.""" + text = path.read_text(encoding="utf-8") + meta, body = _split_front_matter(text) + if meta.get("draft") is True: + return None + + published = _parse_date(meta.get("date")) + if published is None: + return None + + title = str(meta.get("title") or "").strip() or _heading_title(body) or path.stem + description = str(meta.get("description") or "").strip() or _excerpt(body) + categories = tuple( + str(item).strip() for item in meta.get("categories", []) if str(item).strip() + ) + return BlogPost( + title=title, + description=description, + date=published, + slug=path.stem, + categories=categories, + ) + + +def _split_front_matter(text: str) -> tuple[dict[str, object], str]: + """Split YAML front matter from Markdown body.""" + if not text.startswith("---"): + return {}, text + + parts = text.split("---", 2) + if len(parts) < 3: + return {}, text + + try: + meta = yaml.safe_load(parts[1]) or {} + except yaml.YAMLError: + meta = {} + if not isinstance(meta, dict): + meta = {} + return meta, parts[2] + + +def _parse_date(value: object) -> date | None: + """Parse a blog ``date`` field from front matter.""" + if isinstance(value, datetime): + return value.date() + if isinstance(value, date): + return value + if isinstance(value, dict): + return _parse_date(value.get("created") or value.get("date")) + if isinstance(value, str): + try: + return date.fromisoformat(value[:10]) + except ValueError: + return None + return None + + +def _heading_title(body: str) -> str: + """Return the first Markdown heading in ``body``.""" + for line in body.splitlines(): + stripped = line.strip() + if stripped.startswith("#"): + return stripped.lstrip("#").strip() + return "" + + +def _excerpt(body: str) -> str: + """Return a plain-text excerpt for listing descriptions.""" + if EXCERPT_SPLIT in body: + body = body.split(EXCERPT_SPLIT, 1)[0] + + lines: list[str] = [] + for line in body.splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith(("#", "!", "```", ":::")): + continue + lines.append(stripped) + + text = " ".join(lines) + text = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", text) + text = re.sub(r"[*`_]", "", text) + return re.sub(r"\s+", " ", text).strip() + + +def _categories(posts: list[BlogPost]) -> list[str]: + """Return category names sorted alphabetically (case-insensitive).""" + names = {name for post in posts for name in post.categories} + return sorted(names, key=str.lower) + + +def _slugify(name: str) -> str: + """Return a filename-safe slug for a category name.""" + slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") + return slug or "uncategorized" + + +def _format_date(value: date) -> str: + """Return a human-readable date such as ``August 20, 2026``.""" + return f"{value.strftime('%B')} {value.day}, {value.year}" + + +def _badges(post: BlogPost) -> str: + """Return clickable category badges linking to each category page.""" + return " ".join( + f"[{name}](categories/{_slugify(name)}.md){{ .e2s-cat-badge }}" + for name in post.categories + ) + + +def _render_index(posts: list[BlogPost]) -> str: + """Return the blog overview page.""" + lines = [ + GENERATED_NOTICE, + "", + "# Blog", + "", + BLOG_INTRO, + "", + BLOG_SUBSCRIBE_BUTTON, + "", + "## Latest Updates", + ] + if not posts: + lines += ["", "_No posts yet._"] + for post in posts: + link = f"posts/{post.slug}.md" + meta = " ยท ".join(filter(None, [_badges(post), _format_date(post.date)])) + lines += [ + "", + f"### [{post.title}]({link})", + "", + meta, + "", + post.description, + "", + f"[Read post]({link})", + ] + return "\n".join(lines) + "\n" + + +def _render_archive(posts: list[BlogPost]) -> str: + """Return the archive page grouped by year, newest first.""" + lines = [GENERATED_NOTICE, "", "# Archive"] + if not posts: + lines += ["", "_No posts yet._"] + years = sorted({post.date.year for post in posts}, reverse=True) + for year in years: + lines += ["", f"## {year}", ""] + for post in posts: + if post.date.year != year: + continue + link = f"posts/{post.slug}.md" + lines.append(f"- [{post.title}]({link}) ยท {_format_date(post.date)}") + return "\n".join(lines) + "\n" + + +def _render_categories(posts: list[BlogPost], meta: dict[str, str]) -> str: + """Return the categories overview page.""" + lines = [GENERATED_NOTICE, "", "# Categories", "", "Browse posts by topic."] + for name in _categories(posts): + slug = _slugify(name) + description = meta.get(name, DEFAULT_CATEGORY_DESCRIPTION) + lines += [ + "", + f"## [{name}](categories/{slug}.md)", + "", + description, + "", + f"[View posts](categories/{slug}.md)", + ] + return "\n".join(lines) + "\n" + + +def _render_category_page( + name: str, posts: list[BlogPost], meta: dict[str, str] +) -> str: + """Return a single category listing page.""" + description = meta.get(name, DEFAULT_CATEGORY_DESCRIPTION) + lines = [GENERATED_NOTICE, "", f"# {name}", "", description, ""] + for post in posts: + if name not in post.categories: + continue + link = f"../posts/{post.slug}.md" + lines.append(f"- [{post.title}]({link}) ยท {_format_date(post.date)}") + return "\n".join(lines) + "\n" + + +def _prune_categories(keep: set[str]) -> None: + """Remove stale generated category pages no longer backed by a post.""" + if not CATEGORIES_DIR.is_dir(): + return + for path in CATEGORIES_DIR.glob("*.md"): + if path.name not in keep: + path.unlink() + + +def _write(path: Path, content: str) -> None: + """Write ``content`` to ``path``.""" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + +if __name__ == "__main__": + main() diff --git a/docs/generate_blog_rss.py b/docs/generate_blog_rss.py new file mode 100644 index 000000000..e9fb447a3 --- /dev/null +++ b/docs/generate_blog_rss.py @@ -0,0 +1,248 @@ +# SPDX-FileCopyrightText: Copyright (c) 2024-2026 NVIDIA CORPORATION & AFFILIATES. +# SPDX-FileCopyrightText: All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Generate an RSS 2.0 feed from Earth2Studio blog posts.""" + +from __future__ import annotations + +import os +import re +from dataclasses import dataclass +from datetime import date, datetime, timezone +from email.utils import format_datetime +from pathlib import Path +from xml.etree import ElementTree as ET + +import yaml + +DOCS_ROOT = Path(__file__).resolve().parent +REPO_ROOT = DOCS_ROOT.parent +POSTS_DIR = DOCS_ROOT / "blog" / "posts" +FEED_PATH = DOCS_ROOT / "blog" / "feed.xml" +MKDOCS_CONFIG = REPO_ROOT / "mkdocs.yml" +ATOM_NS = "http://www.w3.org/2005/Atom" +EXCERPT_SPLIT = "" + +ET.register_namespace("atom", ATOM_NS) + + +@dataclass(frozen=True) +class BlogPost: + """A published blog post used as an RSS item.""" + + title: str + description: str + date: date + slug: str + categories: tuple[str, ...] + + +def main() -> None: + """Write ``docs/blog/feed.xml`` from published posts.""" + site_url, site_name, site_description = _site_metadata() + public_base = _public_base_url(site_url) + posts = _load_posts() + xml = _render_feed( + posts, + site_name=site_name, + site_description=site_description, + public_base=public_base, + ) + FEED_PATH.parent.mkdir(parents=True, exist_ok=True) + FEED_PATH.write_text(xml, encoding="utf-8") + + +def _site_metadata() -> tuple[str, str, str]: + """Return ``(site_url, site_name, site_description)`` from ``mkdocs.yml``.""" + raw = MKDOCS_CONFIG.read_text(encoding="utf-8") + config = yaml.safe_load(re.sub(r"!!python/name:\S+", "null", raw)) or {} + site_url = str(config.get("site_url") or "").rstrip("/") + "/" + site_name = str(config.get("site_name") or "Earth2Studio") + site_description = str( + config.get("site_description") + or "Earth-2 product and engineering blog posts and updates." + ) + return site_url, site_name, site_description + + +def _public_base_url(site_url: str) -> str: + """Return the versioned public docs URL prefix used in feed links.""" + version = os.getenv("DOC_VERSION", "main").strip() or "main" + return f"{site_url.rstrip('/')}/{version}/" + + +def _load_posts() -> list[BlogPost]: + """Return published posts newest-first.""" + posts: list[BlogPost] = [] + if not POSTS_DIR.is_dir(): + return posts + + for path in sorted(POSTS_DIR.glob("*.md")): + post = _parse_post(path) + if post is not None: + posts.append(post) + + posts.sort(key=lambda item: (item.date, item.slug), reverse=True) + return posts + + +def _parse_post(path: Path) -> BlogPost | None: + """Return a published post, or ``None`` for drafts and undated files.""" + text = path.read_text(encoding="utf-8") + meta, body = _split_front_matter(text) + if meta.get("draft") is True: + return None + + published = _parse_date(meta.get("date")) + if published is None: + return None + + title = str(meta.get("title") or "").strip() or _heading_title(body) or path.stem + description = str(meta.get("description") or "").strip() or _excerpt(body) + categories = tuple( + str(item).strip() + for item in meta.get("categories", []) + if str(item).strip() + ) + return BlogPost( + title=title, + description=description, + date=published, + slug=path.stem, + categories=categories, + ) + + +def _split_front_matter(text: str) -> tuple[dict[str, object], str]: + """Split YAML front matter from Markdown body.""" + if not text.startswith("---"): + return {}, text + + parts = text.split("---", 2) + if len(parts) < 3: + return {}, text + + try: + meta = yaml.safe_load(parts[1]) or {} + except yaml.YAMLError: + meta = {} + if not isinstance(meta, dict): + meta = {} + return meta, parts[2] + + +def _parse_date(value: object) -> date | None: + """Parse a blog ``date`` field from front matter.""" + if isinstance(value, datetime): + return value.date() + if isinstance(value, date): + return value + if isinstance(value, dict): + return _parse_date(value.get("created") or value.get("date")) + if isinstance(value, str): + try: + return date.fromisoformat(value[:10]) + except ValueError: + return None + return None + + +def _heading_title(body: str) -> str: + """Return the first Markdown heading in ``body``.""" + for line in body.splitlines(): + stripped = line.strip() + if stripped.startswith("#"): + return stripped.lstrip("#").strip() + return "" + + +def _excerpt(body: str) -> str: + """Return a plain-text excerpt for the RSS description.""" + if EXCERPT_SPLIT in body: + body = body.split(EXCERPT_SPLIT, 1)[0] + + lines: list[str] = [] + for line in body.splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith(("#", "!", "```", ":::")): + continue + lines.append(stripped) + + text = " ".join(lines) + text = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", text) + text = re.sub(r"[*`_]", "", text) + return re.sub(r"\s+", " ", text).strip() + + +def _render_feed( + posts: list[BlogPost], + *, + site_name: str, + site_description: str, + public_base: str, +) -> str: + """Return RSS 2.0 XML for ``posts``.""" + channel_link = f"{public_base}blog/" + feed_link = f"{public_base}blog/feed.xml" + + rss = ET.Element("rss", {"version": "2.0"}) + channel = ET.SubElement(rss, "channel") + _set_text(channel, "title", f"{site_name} Blog") + _set_text(channel, "link", channel_link) + _set_text(channel, "description", site_description) + _set_text(channel, "language", "en") + _set_text(channel, "ttl", "1440") + + atom_link = ET.SubElement(channel, f"{{{ATOM_NS}}}link") + atom_link.set("href", feed_link) + atom_link.set("rel", "self") + atom_link.set("type", "application/rss+xml") + + for post in posts: + item_link = f"{public_base}blog/posts/{post.slug}/" + item = ET.SubElement(channel, "item") + _set_text(item, "title", post.title) + _set_text(item, "link", item_link) + _set_text(item, "guid", item_link) + _set_text(item, "pubDate", _rfc822(post.date)) + _set_text( + item, + "description", + post.description or post.title, + ) + for category in post.categories: + _set_text(item, "category", category) + + xml = ET.tostring(rss, encoding="unicode") + return '\n' + xml + "\n" + + +def _set_text(parent: ET.Element, tag: str, text: str) -> None: + """Append a child element with ``text``.""" + child = ET.SubElement(parent, tag) + child.text = text + + +def _rfc822(value: date) -> str: + """Return an RFC 822 timestamp at midnight UTC.""" + published = datetime( + value.year, value.month, value.day, tzinfo=timezone.utc + ) + return format_datetime(published) + + +if __name__ == "__main__": + main() diff --git a/docs/overrides/main.html b/docs/overrides/main.html index 945e96131..df48ec1e1 100644 --- a/docs/overrides/main.html +++ b/docs/overrides/main.html @@ -1,5 +1,10 @@ {% extends "base.html" %} +{% block extrahead %} + {{ super() }} + +{% endblock %} + {% block announce %} {% include "partials/announce.html" %} {% endblock %} diff --git a/docs/overrides/partials/content.html b/docs/overrides/partials/content.html new file mode 100644 index 000000000..55e55b8f9 --- /dev/null +++ b/docs/overrides/partials/content.html @@ -0,0 +1,23 @@ +{#- + Overrides Zensical's built-in partials/content.html. + + Zensical has no blog plugin, so it never renders an author byline on blog + posts. This override injects a byline (authors, date, categories) right + after the post title for pages that carry blog front matter (a `date` and + `authors`). The byline markup comes from a generated macro that bakes in the + `.authors.yml` data (see docs/generate_blog_index.py). +-#} +{% import "partials/_blog_byline.html" as e2sblog %} +{% include "partials/actions.html" %} +{% if "{{ page.title | d(config.site_name, true) }} +{% endif %} +{% if page.meta and page.meta.date and page.meta.authors %} + {{ page.content | replace("", "" ~ e2sblog.e2s_byline(page)) }} +{% else %} + {{ page.content }} +{% endif %} +{% include "partials/tags.html" %} +{% include "partials/source-file.html" %} +{% include "partials/feedback.html" %} +{% include "partials/comments.html" %} diff --git a/mkdocs.yml b/mkdocs.yml index 149aa444c..a7c78c11f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -66,6 +66,9 @@ extra: - icon: fontawesome/brands/github link: https://github.com/NVIDIA/earth2studio name: Earth2Studio on GitHub + - icon: fontawesome/solid/rss + link: https://nvidia.github.io/earth2studio/main/blog/feed.xml + name: Earth2Studio Blog RSS markdown_extensions: - mkdocs_badges.zensical - admonition @@ -429,6 +432,8 @@ plugins: post_date_format: medium archive: true categories: true + authors: true + authors_file: '{blog}/.authors.yml' - mike: alias_type: redirect hooks: @@ -489,16 +494,14 @@ nav: - Recipes: recipes/index.md - Blog: - Overview: blog/index.md - - Posts: - - MkDocs Material Migration: blog/posts/mkdocs-upgrade.md - Archive: blog/archive.md - - Categories: - - Overview: blog/categories.md - - Documentation: blog/categories/documentation.md + - Categories: blog/categories.md - Changelog: https://github.com/NVIDIA/earth2studio/blob/main/CHANGELOG.md not_in_nav: | 404.md modules/generated/** examples/** + blog/posts/** + blog/categories/*.md userguide/about/index.md userguide/about/intro.md diff --git a/test/_ci/build_docs_examples.sh b/test/_ci/build_docs_examples.sh index 3e8b0636c..5873c951e 100755 --- a/test/_ci/build_docs_examples.sh +++ b/test/_ci/build_docs_examples.sh @@ -31,6 +31,8 @@ echo "Full docs-full log: ${main_log}" "${uv_docs[@]}" python docs/generate_api.py "${uv_docs[@]}" python docs/generate_catalog.py "${uv_docs[@]}" python docs/generate_install_options.py +"${uv_docs[@]}" python docs/generate_blog_rss.py +"${uv_docs[@]}" python docs/generate_blog_index.py # Rebuild examples from source, section by section, so stale examples are refreshed. rm -rf docs/examples examples/outputs From c083b2dec81a6df15d7fa2c2ed0df792969a6c76 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 21 Aug 2026 02:05:15 +0000 Subject: [PATCH 4/4] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- docs/generate_blog_index.py | 3 +-- docs/generate_blog_rss.py | 8 ++------ 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/docs/generate_blog_index.py b/docs/generate_blog_index.py index 007457806..e70402c3d 100644 --- a/docs/generate_blog_index.py +++ b/docs/generate_blog_index.py @@ -56,8 +56,7 @@ BLOG_INTRO = "Earth-2 Product and Engineering Blog Posts and Updates." BLOG_SUBSCRIBE_BUTTON = ( - "[:fontawesome-solid-rss: Subscribe via RSS](feed.xml)" - "{ .e2s-blog-subscribe }" + "[:fontawesome-solid-rss: Subscribe via RSS](feed.xml)" "{ .e2s-blog-subscribe }" ) DEFAULT_CATEGORY_DESCRIPTION = "Posts filed under this topic." GENERATED_NOTICE = ( diff --git a/docs/generate_blog_rss.py b/docs/generate_blog_rss.py index e9fb447a3..78641defb 100644 --- a/docs/generate_blog_rss.py +++ b/docs/generate_blog_rss.py @@ -113,9 +113,7 @@ def _parse_post(path: Path) -> BlogPost | None: title = str(meta.get("title") or "").strip() or _heading_title(body) or path.stem description = str(meta.get("description") or "").strip() or _excerpt(body) categories = tuple( - str(item).strip() - for item in meta.get("categories", []) - if str(item).strip() + str(item).strip() for item in meta.get("categories", []) if str(item).strip() ) return BlogPost( title=title, @@ -238,9 +236,7 @@ def _set_text(parent: ET.Element, tag: str, text: str) -> None: def _rfc822(value: date) -> str: """Return an RFC 822 timestamp at midnight UTC.""" - published = datetime( - value.year, value.month, value.day, tzinfo=timezone.utc - ) + published = datetime(value.year, value.month, value.day, tzinfo=timezone.utc) return format_datetime(published)