Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ on:
# PRs. To bump the pin, update FIXTURES_SHA below.
env:
FIXTURES_REPO: QuantEcon/quantecon-book-theme-fixtures
FIXTURES_SHA: d8ffc17c753ecf45fa25c6062827e1aa9de201b3
# Includes the announcement-banner demo in the fixtures config (merged in
# fixtures #1), so the preview/visual build exercises the banner.
FIXTURES_SHA: 32763e43b893c1520df8e58c0c6e10d81db7f1ac

# Explicit least-privilege permissions:
# contents: write — checkout + upload-artifact + nwtgck/actions-netlify
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/update-snapshots.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ on:
# "Resolve fixtures pin" step.
env:
FIXTURES_REPO: QuantEcon/quantecon-book-theme-fixtures
FIXTURES_SHA: d8ffc17c753ecf45fa25c6062827e1aa9de201b3
FIXTURES_SHA: 32763e43b893c1520df8e58c0c6e10d81db7f1ac

jobs:
# /update-new-snapshots — only creates MISSING snapshots (safe for adding new tests)
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- **Dismissible announcement banner** — a new `announcement` theme option renders a notice at the top of every page (HTML allowed, so you can include emphasis and a link to changelog notes). Readers dismiss it with a `×`; the dismissal persists in `localStorage` and is keyed to a hash of the message, so editing the text re-shows the banner for everyone who dismissed the previous one. An optional `announcement_expires` ISO date (`YYYY-MM-DD`) auto-hides the banner after that day — enforced client-side (so it disappears for visitors even without a rebuild) and at build time (an already-expired notice is omitted from the HTML). An invalid expiry date logs a warning and fails open. Two looks are available via `announcement_style`: `bar` (default) — a thin full-width strip that scrolls away below the toolbar — or `callout` — a boxed in-column notice; both adapt to dark mode and RTL. The banner defaults to empty/off, so existing sites are unaffected. The renderer iterates a list of notices internally so per-page announcements can be added additively later (tracked in #403).

### Documentation
- **Developer setup troubleshooting for stale `.nodeenv`** — documented the `nodeenv-version-mismatch` error (an in-repo `.nodeenv/` left over from an older pinned Node.js version) and its fix (`rm -rf .nodeenv` then rebuild), which otherwise blocks `tox` and editable installs locally. Also clarified that `tox` keeps the toolchain fully repo-local (`.tox/`, `.nodeenv/`, `node_modules/` are all git-ignored and regenerated), so nothing is installed into the base/global environment.

Expand Down
99 changes: 99 additions & 0 deletions docs/user/announcements.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# Announcement Banner

Display a dismissible announcement at the top of every page — useful for
site-wide notices such as a software upgrade, a link to changelog notes, or a
scheduled maintenance window.

```{contents}
:local:
:depth: 1
```

## Basic usage

Set the `announcement` option in `html_theme_options`. The value is an HTML
string, so you can include emphasis and a link:

```yaml
sphinx:
config:
html_theme_options:
announcement: 'We upgraded to <strong>Anaconda 2026.06</strong> — see the <a href="/status.html">CHANGELOG</a>.'
```

For a `conf.py` project:

```python
html_theme_options = {
"announcement": 'We upgraded to <strong>Anaconda 2026.06</strong> — see the <a href="/status.html">CHANGELOG</a>.',
}
```

Leave the option empty (the default) and no banner is shown.

## Style

Choose how the banner looks with `announcement_style`:

```yaml
sphinx:
config:
html_theme_options:
announcement: 'We upgraded to <strong>Anaconda 2026.06</strong>.'
announcement_style: bar # "bar" (default) or "callout"
```

- **`bar`** (default) — a thin, full-width strip with centered text, sitting just
below the toolbar and scrolling away with the page. Discreet; good for standing
notices.
- **`callout`** — a boxed notice in the content column with an accent border.
More prominent; good for a notice you want to stand out.

Both styles adapt to dark mode and right-to-left layouts, and both are
dismissible. An unrecognized value logs a warning and falls back to `bar`.

## Dismissal

Readers can dismiss the banner with the `×` button. The dismissal is remembered
in the browser's `localStorage`, so it stays hidden on future visits.

Dismissal is keyed to the **content of the message**. When you change the
`announcement` text, the banner re-appears for everyone — including readers who
dismissed the previous message. This means you can reuse the banner for a new
notice without worrying that people who dismissed the last one will miss it.

## Expiry date

Add an optional `announcement_expires` date (ISO `YYYY-MM-DD`) to have the
banner disappear automatically. The banner shows **through the end of** that
day, in the reader's local timezone:

```yaml
sphinx:
config:
html_theme_options:
announcement: 'We upgraded to <strong>Anaconda 2026.06</strong> — see the <a href="/status.html">CHANGELOG</a>.'
announcement_expires: "2026-07-01"
```

The expiry is enforced two ways, so it works whether or not the site is rebuilt:

- **In the reader's browser** — the banner hides itself at the end of the expiry
day in the reader's local timezone, even if the published site has not been
rebuilt since.
- **At build time** — once the expiry day is well past (a one-day grace ensures
it has ended in every timezone), the banner is omitted from the generated HTML
entirely. Around the expiry date the markup is still emitted and the per-reader
browser check above governs exactly when it disappears.

If `announcement_expires` is not a valid `YYYY-MM-DD` date, the build logs a
warning and ignores the expiry (the banner keeps showing) — a typo will never
silently hide an active announcement.

## Per-page announcements

Per-page announcements (for example, flagging that a single lecture now uses a
newer library version) are not yet supported. Progress is tracked in
[issue #403](https://github.com/QuantEcon/quantecon-book-theme/issues/403); the
banner is built to accept per-page notices additively, so this can be added
without changing how the site-wide `announcement` option works.
17 changes: 17 additions & 0 deletions docs/user/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,23 @@ html_theme_options = {

This disables the left navigation bar.

## Announcement Banner

Show a dismissible notice at the top of every page (for example, a software
upgrade with a link to changelog notes):

```python
html_theme_options = {
...
"announcement": 'We upgraded to <strong>Anaconda 2026.06</strong> — see the <a href="/status.html">CHANGELOG</a>.',
"announcement_expires": "2026-07-01", # optional ISO date; banner auto-hides after this day
...
}
```

See [Announcement Banner](announcements.md) for dismissal behavior and expiry
details.

## Add Authors

Display a list of authors just below the page title:
Expand Down
1 change: 1 addition & 0 deletions docs/user/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ html_theme_options = {
:caption: User Guide

configuration
announcements
layout
notebooks
launch
Expand Down
97 changes: 96 additions & 1 deletion src/quantecon_book_theme/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import hashlib
from functools import lru_cache
import subprocess
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone

from docutils import nodes
from sphinx.util import logging
Expand Down Expand Up @@ -223,6 +223,58 @@ def get_relative_time(past_date):
return f"{years} year{'s' if years != 1 else ''} ago"


def _parse_iso_date(value):
"""Parse a ``YYYY-MM-DD`` string into a ``date``, or return ``None``."""
if not value:
return None
try:
return datetime.strptime(value.strip(), "%Y-%m-%d").date()
except (ValueError, TypeError):
return None


def _build_announcements(config_theme):
"""Build the list of announcements to render in the page banner.

Currently this is a single site-wide announcement (from the ``announcement``
option), but it returns a *list* so per-page announcements can be appended
additively in future without changing the template or JavaScript (tracked in
GitHub issue #403). Each entry is a dict with:

- ``html``: the (trusted) HTML message
- ``id``: a short content hash, used to key dismissal in localStorage so an
edited message re-appears for everyone who dismissed the old one
- ``expires_iso``: the ISO expiry date (or ``""``), checked client-side so the
banner disappears for visitors on the date even without a rebuild

An announcement whose expiry has already passed at build time is omitted
entirely; the client-side check handles expiry that falls between builds.
"""
announcements = []
message = (config_theme.get("announcement") or "").strip()
if message:
expires = (config_theme.get("announcement_expires") or "").strip()
expires_date = _parse_iso_date(expires)
# Build-time skip is only an optimization: drop a clearly-stale notice so
# it isn't shipped in the HTML at all. Keep it conservative — the expiry
# day ends at different UTC instants across timezones, and the client
# side hides the banner precisely per-reader, so we only skip once the
# date is past for every real-world timezone (a one-day UTC grace
# covers the full UTC-12..UTC+14 range).
today_utc = datetime.now(timezone.utc).date()
if expires_date is not None and today_utc > expires_date + timedelta(days=1):
return announcements
announcement_id = hashlib.sha1(message.encode("utf-8")).hexdigest()[:12]
announcements.append(
{
"html": message,
"id": announcement_id,
"expires_iso": expires if expires_date is not None else "",
}
)
return announcements


def _process_languages(config_theme):
"""Validate and normalize language switcher configuration.

Expand Down Expand Up @@ -492,6 +544,10 @@ def get_github_src_folder(app):
config_theme
)

# Build the announcement banner list (currently site-wide only; the list
# shape leaves room for additive per-page announcements later).
context["announcements"] = _build_announcements(config_theme)

# Make sure the context values are bool
blns = [
"theme_use_edit_page_button",
Expand Down Expand Up @@ -627,6 +683,44 @@ def _string_or_bool(var):
return var is None


# Announcement banner styles. "bar" is a thin full-width strip; "callout" is
# the original boxed in-column notice.
_VALID_ANNOUNCEMENT_STYLES = ["bar", "callout"]
_DEFAULT_ANNOUNCEMENT_STYLE = "bar"


def validate_announcement(app):
"""Validate the announcement options once, at build start.

Fails open: an unparseable ``announcement_expires`` is dropped (cleared) so a
typo can never silently hide an active announcement, and an unknown
``announcement_style`` falls back to the default. Warnings are logged so the
misconfiguration surfaces during the build.
"""
theme_options = app.config.html_theme_options
expires = theme_options.get("announcement_expires", "")
if expires and _parse_iso_date(expires) is None:
SPHINX_LOGGER.warning(
"Invalid announcement_expires %r. Expected ISO date YYYY-MM-DD. "
"Ignoring expiry; the announcement will not auto-expire.",
expires,
)
theme_options["announcement_expires"] = ""

style = str(theme_options.get("announcement_style", "") or "").strip().lower()
if not style:
style = _DEFAULT_ANNOUNCEMENT_STYLE
elif style not in _VALID_ANNOUNCEMENT_STYLES:
SPHINX_LOGGER.warning(
"Unknown announcement_style %r. Valid styles: %s. Falling back to %r.",
style,
", ".join(_VALID_ANNOUNCEMENT_STYLES),
_DEFAULT_ANNOUNCEMENT_STYLE,
)
style = _DEFAULT_ANNOUNCEMENT_STYLE
theme_options["announcement_style"] = style


# Built-in text color schemes
_VALID_COLOR_SCHEMES = ["seoul256", "gruvbox", "none"]

Expand Down Expand Up @@ -676,6 +770,7 @@ def setup(app):

app.connect("html-page-context", add_hub_urls)
app.connect("builder-inited", add_plugins_list)
app.connect("builder-inited", validate_announcement)
app.connect("builder-inited", validate_color_scheme)
app.connect("builder-inited", setup_pygments_css)
app.connect("html-page-context", hash_html_assets)
Expand Down
93 changes: 93 additions & 0 deletions src/quantecon_book_theme/assets/scripts/announcement.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/**
* Announcement Banner Module
*
* Renders a dismissible announcement bar at the top of the page. The bar is
* server-rendered hidden and revealed here only for rows that survive two
* checks, so the reader never sees a flash of a notice they already dismissed
* or one that has expired:
*
* - Dismissed: each row carries a content hash (`data-announcement-id`). The
* set of dismissed ids is stored in localStorage, so an edited message —
* which produces a new hash — re-appears even for readers who dismissed the
* old one. Dismissal persists across visits until the message changes.
* - Expired: an optional `data-announcement-expires` (YYYY-MM-DD) hides the
* row once the visitor's clock is past the end of that day, so a notice
* disappears on the date even if the site has not been rebuilt.
*
* The bar may hold more than one row; the logic is intentionally n-aware so
* per-page announcements can be added additively later without changes here.
*/

const STORAGE_KEY = "qe-dismissed-announcements";
const MAX_REMEMBERED = 20;

function readDismissed() {
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
const parsed = raw ? JSON.parse(raw) : [];
return Array.isArray(parsed) ? parsed : [];
} catch (e) {
// localStorage unavailable (e.g. private mode) or corrupt value.
return [];
}
}

function rememberDismissed(id) {
try {
const dismissed = readDismissed().filter((value) => value !== id);
dismissed.push(id);
// Cap the list so it can't grow unbounded over the site's lifetime.
const trimmed = dismissed.slice(-MAX_REMEMBERED);
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(trimmed));
} catch (e) {
// Persisting is best-effort; dismissal still works for this page view.
}
}

function isExpired(expires) {
if (!expires) return false;
// Parse YYYY-MM-DD into explicit local-time components (unambiguous across
// engines, and not subject to the "date-only string is UTC" parsing rule).
// Expire at the very end of that calendar day in the visitor's timezone.
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(expires.trim());
if (!m) return false; // fail open on bad/unsupported format
const endOfDay = new Date(+m[1], +m[2] - 1, +m[3], 23, 59, 59, 999);
if (Number.isNaN(endOfDay.getTime())) return false;
return new Date() > endOfDay;
}

export function initAnnouncement() {
const bar = document.querySelector(".qe-announcement-bar");
if (!bar) return;

const dismissed = readDismissed();

bar.querySelectorAll(".qe-announcement").forEach((row) => {
const id = row.getAttribute("data-announcement-id");
const expires = row.getAttribute("data-announcement-expires");

// Drop rows the reader has dismissed or that have expired.
if (dismissed.includes(id) || isExpired(expires)) {
row.remove();
return;
}

const closeButton = row.querySelector(".qe-announcement__close");
if (closeButton) {
closeButton.addEventListener("click", function () {
rememberDismissed(id);
row.remove();
if (!bar.querySelector(".qe-announcement")) {
bar.setAttribute("hidden", "");
}
});
}
});

// Reveal the bar only if at least one row survived.
if (bar.querySelector(".qe-announcement")) {
bar.removeAttribute("hidden");
} else {
bar.remove();
}
}
Loading
Loading