diff --git a/great_docs/_versioned_build.py b/great_docs/_versioned_build.py index 4e70e516..ccb0ea61 100644 --- a/great_docs/_versioned_build.py +++ b/great_docs/_versioned_build.py @@ -42,6 +42,24 @@ def _collect_qmd_files(source_dir: Path) -> list[Path]: return sorted(files) +_FRONTMATTER_VALUE_RE = _re.compile(r"^---\s*\n(.*?)\n---\s*\n", _re.DOTALL) + + +def _extract_frontmatter_value(content: str, key: str) -> str | None: + """Extract a scalar value for *key* from YAML frontmatter, or `None`.""" + m = _FRONTMATTER_VALUE_RE.match(content) + if not m: + return None + fm = m.group(1) + # Simple line-based extraction that handles `key: value` and `key: "value"` + pattern = _re.compile(rf"^{_re.escape(key)}\s*:\s*(.+)$", _re.MULTILINE) + km = pattern.search(fm) + if not km: + return None + val = km.group(1).strip().strip('"').strip("'") + return val + + def _prune_cli_pages(dest_dir: Path, snap: object) -> None: """ Remove CLI reference QMD files for commands not in the snapshot. @@ -256,6 +274,7 @@ def preprocess_version( all_versions: list[VersionEntry], project_root: Path | None = None, section_configs: list[dict] | None = None, + badge_expiry: "BadgeExpiry | None" = None, ) -> list[str]: """ Preprocess the documentation source for a single version. @@ -351,7 +370,16 @@ def preprocess_version( # 6. Expand inline [version-badge] markers and version callouts for qmd_file in _collect_qmd_files(dest_dir): content = qmd_file.read_text(encoding="utf-8", errors="replace") - updated = expand_version_badges(content, entry) + + # Per-page new-is-old override + page_expiry = badge_expiry + page_override = _extract_frontmatter_value(content, "new-is-old") + if page_override is not None: + from great_docs._versioning import parse_badge_expiry + + page_expiry = parse_badge_expiry(page_override) + + updated = expand_version_badges(content, entry, all_versions, page_expiry) updated = expand_version_callouts(updated, entry) if updated != content: qmd_file.write_text(updated, encoding="utf-8") @@ -802,30 +830,50 @@ def _rebuild_api_from_git_ref( ) -def expand_version_badges(content: str, entry: VersionEntry) -> str: +def expand_version_badges( + content: str, + entry: VersionEntry, + versions: list[VersionEntry] | None = None, + expiry: "BadgeExpiry | None" = None, +) -> str: """ Expand `[version-badge new]` and `[version-badge changed 0.3]` inline markers into HTML `` badges. If no version is specified in the marker, the current entry's label is used. + When *expiry* is provided and a `new` badge is expired, the marker is removed entirely (no HTML + emitted). `changed` and `deprecated` badges are never affected by expiry. + Parameters ---------- content The `.qmd` file content. entry The version being built. + versions + The full ordered list of version entries (needed for expiry evaluation). + expiry + Badge expiry policy. `None` means never expire. Returns ------- str Content with markers replaced by HTML spans. """ + from great_docs._versioning import BADGE_EXPIRY_NEVER, is_badge_expired + + effective_expiry = expiry or BADGE_EXPIRY_NEVER def _replace(m: _re.Match) -> str: badge_type = m.group(1).lower() version = m.group(2) or entry.label + # Check expiry for "new" badges only + if badge_type == "new" and versions and effective_expiry.mode != "never": + if is_badge_expired(version, entry, versions, effective_expiry): + return "" + css_class = f"gd-badge gd-badge-{badge_type}" if badge_type == "new": label = f"New in {version}" @@ -1274,6 +1322,7 @@ def run_versioned_build( site_url: str | None = None, progress_callback: Callable[[int, int, int], None] | None = None, on_renders_done: Callable[[], None] | None = None, + badge_expiry_raw: str | None = None, ) -> dict[str, Any]: """ Orchestrate a full multi-version build. @@ -1315,6 +1364,11 @@ def run_versioned_build( latest = get_latest_version(versions) latest_tag = latest.tag if latest else versions[0].tag + # Parse badge expiry config + from great_docs._versioning import parse_badge_expiry + + badge_expiry = parse_badge_expiry(badge_expiry_raw) + # Filter versions based on CLI flags if latest_only: targets = [v for v in versions if v.tag == latest_tag] @@ -1349,6 +1403,7 @@ def run_versioned_build( entry, versions, project_root=project_root, + badge_expiry=badge_expiry, ) _prune_missing_sidebar_pages(ver_dir) _rewrite_quarto_yml_for_version(ver_dir, entry, latest_tag, site_url=site_url) diff --git a/great_docs/_versioning.py b/great_docs/_versioning.py index 8a2dee90..8ef47092 100644 --- a/great_docs/_versioning.py +++ b/great_docs/_versioning.py @@ -2,6 +2,7 @@ import re from dataclasses import dataclass, field +from datetime import date from typing import Any @@ -16,12 +17,70 @@ class VersionEntry: eol: bool = False api_snapshot: str | None = None git_ref: str | None = None + released: str | None = None # Positional index in the versions list (0 = newest). # Set by parse_versions_config after construction. _index: int = field(default=0, repr=False) +@dataclass +class BadgeExpiry: + """Controls when 'new' badges stop rendering.""" + + mode: str # "releases" | "minor_releases" | "version" | "date" | "days" | "never" + value: int | str = 0 # count, version tag, ISO date string, or day count + + +# Sentinel for "never expire" +BADGE_EXPIRY_NEVER = BadgeExpiry(mode="never") + + +_BADGE_EXPIRY_RE = re.compile(r"^(\d+)\s+(releases?|minor\s+releases?)$", re.IGNORECASE) +_BADGE_EXPIRY_DAYS_RE = re.compile(r"^(\d+)\s+days?$", re.IGNORECASE) +_BADGE_EXPIRY_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") + + +def parse_badge_expiry(raw: str | None) -> BadgeExpiry: + """ + Parse a `new_is_old` value into a `BadgeExpiry`. + + Accepted forms:: + + "never" → BadgeExpiry("never") + "3 releases" → BadgeExpiry("releases", 3) + "2 minor releases" → BadgeExpiry("minor_releases", 2) + "0.8" → BadgeExpiry("version", "0.8") + "2026-06-01" → BadgeExpiry("date", "2026-06-01") + "180 days" → BadgeExpiry("days", 180) + """ + if raw is None or str(raw).strip().lower() == "never": + return BADGE_EXPIRY_NEVER + + raw = str(raw).strip() + + # "3 releases" or "2 minor releases" + m = _BADGE_EXPIRY_RE.match(raw) + if m: + count = int(m.group(1)) + kind = m.group(2).lower() + if "minor" in kind: + return BadgeExpiry(mode="minor_releases", value=count) + return BadgeExpiry(mode="releases", value=count) + + # "180 days" + m = _BADGE_EXPIRY_DAYS_RE.match(raw) + if m: + return BadgeExpiry(mode="days", value=int(m.group(1))) + + # "2026-06-01" (ISO date) + if _BADGE_EXPIRY_DATE_RE.match(raw): + return BadgeExpiry(mode="date", value=raw) + + # Bare version tag: "0.8", "v1.2", etc. + return BadgeExpiry(mode="version", value=raw) + + def parse_versions_config(raw: list[Any]) -> list[VersionEntry]: """ Parse the `versions:` list from great-docs.yml. @@ -66,6 +125,7 @@ def parse_versions_config(raw: list[Any]) -> list[VersionEntry]: eol=bool(item.get("eol", False)), api_snapshot=item.get("api_snapshot"), git_ref=item.get("git_ref"), + released=item.get("released"), ) else: raise ValueError(f"versions[{i}]: expected a string or dict, got {type(item).__name__}") @@ -199,6 +259,100 @@ def evaluate_version_expr( return True +# --------------------------------------------------------------------------- +# Badge expiry evaluation +# --------------------------------------------------------------------------- + + +def is_badge_expired( + badge_version: str, + target_entry: VersionEntry, + versions: list[VersionEntry], + expiry: BadgeExpiry, +) -> bool: + """ + Determine whether a `[version-badge new VERSION]` should be suppressed. + + Parameters + ---------- + badge_version + The version tag written in the badge (e.g. `"0.5"`). + target_entry + The version currently being built. + versions + The full ordered list of version entries. + expiry + The badge expiry policy. + + Returns + ------- + bool + `True` if the badge should **not** be rendered (expired). + """ + if expiry.mode == "never": + return False + + if expiry.mode == "releases": + badge_idx = _resolve_index(badge_version, versions) + if badge_idx is None: + return False + distance = badge_idx - target_entry._index # positive = target is newer + return distance >= int(expiry.value) + + if expiry.mode == "minor_releases": + # Filter out prerelease entries for counting + non_pre = [v for v in versions if not v.prerelease] + badge_idx = _resolve_index(badge_version, non_pre) + target_idx = _resolve_index(target_entry.tag, non_pre) + # Prerelease target (e.g. dev) isn't in non_pre — fall back to + # the latest non-prerelease so dev expires at least as much as latest. + if target_idx is None and non_pre: + target_idx = non_pre[0]._index + if badge_idx is None or target_idx is None: + return False + distance = badge_idx - target_idx + return distance >= int(expiry.value) + + if expiry.mode == "version": + # Expire when building the threshold version or later + threshold_idx = _resolve_index(str(expiry.value), versions) + if threshold_idx is None: + return False + return target_entry._index <= threshold_idx + + if expiry.mode == "date": + try: + cutoff = date.fromisoformat(str(expiry.value)) + except ValueError: + return False + return date.today() >= cutoff + + if expiry.mode == "days": + badge_entry = _find_entry(badge_version, versions) + if badge_entry is None or not badge_entry.released: + return False # fail open + try: + released = date.fromisoformat(str(badge_entry.released)[:10]) + except ValueError: + return False + elapsed = (date.today() - released).days + return elapsed >= int(expiry.value) + + return False + + +def _find_entry(tag: str, versions: list[VersionEntry]) -> VersionEntry | None: + """Find a VersionEntry by tag, with v-prefix fallback.""" + for v in versions: + if v.tag == tag: + return v + alt = tag[1:] if tag.startswith("v") else f"v{tag}" + for v in versions: + if v.tag == alt: + return v + return None + + # --------------------------------------------------------------------------- # Version fence preprocessing # --------------------------------------------------------------------------- @@ -215,9 +369,7 @@ def evaluate_version_expr( _HEADING_RE = re.compile(r"^(#{1,6})\s") # Matches a heading with a [version-badge new VERSION] marker -_HEADING_BADGE_NEW_RE = re.compile( - r"^(#{1,6})\s+.*\[version-badge\s+new\s+([^\]]+)\]" -) +_HEADING_BADGE_NEW_RE = re.compile(r"^(#{1,6})\s+.*\[version-badge\s+new\s+([^\]]+)\]") def process_version_fences( @@ -232,7 +384,7 @@ def process_version_fences( divs. Matching blocks have their fence markers removed (content kept); non-matching blocks are removed entirely. - Headings with ``[version-badge new VERSION]`` act as implicit section fences: when the target + Headings with `[version-badge new VERSION]` act as implicit section fences: when the target version is older than VERSION, the heading and all content until the next heading at the same or higher level are removed. This prevents orphan headings that appear with no content below them. @@ -291,7 +443,10 @@ def process_version_fences( i += 1 continue elif in_code_block: - if stripped.startswith(code_fence_pattern) and stripped.rstrip(code_fence_pattern[0]) == "": + if ( + stripped.startswith(code_fence_pattern) + and stripped.rstrip(code_fence_pattern[0]) == "" + ): in_code_block = False code_fence_pattern = "" if skip_heading_level == 0 and (not stack or stack[-1][0]): @@ -460,7 +615,7 @@ def page_matches_version( The version tag being built. versions The full ordered list of version entries. When provided, version expressions - (e.g. ``">=0.7"``) are evaluated; otherwise only bare tag matching is used. + (e.g. `">=0.7"`) are evaluated; otherwise only bare tag matching is used. Returns ------- diff --git a/great_docs/core.py b/great_docs/core.py index 60824261..3fe0e3fd 100644 --- a/great_docs/core.py +++ b/great_docs/core.py @@ -13008,6 +13008,7 @@ def _on_renders_done() -> None: latest_only=latest_only, progress_callback=_progress_cb, on_renders_done=_on_renders_done, + badge_expiry_raw=self._config.get("new_is_old"), ) if not vb_result["success"]: diff --git a/tests/test_versioned_build.py b/tests/test_versioned_build.py index 58f32aa9..6f913cfe 100644 --- a/tests/test_versioned_build.py +++ b/tests/test_versioned_build.py @@ -556,6 +556,54 @@ def test_case_insensitive(self): result = expand_version_badges(content, entry) assert "New in 0.3.0" in result + def test_new_badge_expired_suppressed(self): + from great_docs._versioning import BadgeExpiry, parse_versions_config + + versions = parse_versions_config(["0.7", "0.6", "0.5", "0.4", "0.3"]) + entry = versions[0] # 0.7 + expiry = BadgeExpiry(mode="releases", value=3) + content = "Feature [version-badge new 0.3] here." + result = expand_version_badges(content, entry, versions, expiry) + assert "gd-badge" not in result + assert "Feature here." in result + + def test_new_badge_within_window_kept(self): + from great_docs._versioning import BadgeExpiry, parse_versions_config + + versions = parse_versions_config(["0.7", "0.6", "0.5", "0.4", "0.3"]) + entry = versions[2] # 0.5 + expiry = BadgeExpiry(mode="releases", value=3) + content = "Feature [version-badge new 0.3] here." + result = expand_version_badges(content, entry, versions, expiry) + assert "New in 0.3" in result + + def test_changed_badge_never_expired(self): + from great_docs._versioning import BadgeExpiry, parse_versions_config + + versions = parse_versions_config(["0.7", "0.6", "0.5", "0.4", "0.3"]) + entry = versions[0] # 0.7 + expiry = BadgeExpiry(mode="releases", value=1) + content = "[version-badge changed 0.3]" + result = expand_version_badges(content, entry, versions, expiry) + assert "Changed in 0.3" in result + + def test_deprecated_badge_never_expired(self): + from great_docs._versioning import BadgeExpiry, parse_versions_config + + versions = parse_versions_config(["0.7", "0.6", "0.5", "0.4", "0.3"]) + entry = versions[0] # 0.7 + expiry = BadgeExpiry(mode="releases", value=1) + content = "[version-badge deprecated 0.3]" + result = expand_version_badges(content, entry, versions, expiry) + assert "Deprecated in 0.3" in result + + def test_no_expiry_backward_compatible(self): + """Calling without expiry params still works (backward compatible).""" + entry = VersionEntry(tag="0.7", label="0.7.0") + content = "Feature [version-badge new 0.3] here." + result = expand_version_badges(content, entry) + assert "New in 0.3" in result + # --------------------------------------------------------------------------- # Version callout expansion diff --git a/tests/test_versioning.py b/tests/test_versioning.py index 0a178431..4bde4626 100644 --- a/tests/test_versioning.py +++ b/tests/test_versioning.py @@ -3,12 +3,16 @@ import pytest from great_docs._versioning import ( + BADGE_EXPIRY_NEVER, + BadgeExpiry, VersionEntry, build_version_map, evaluate_version_expr, extract_page_versions, get_latest_version, + is_badge_expired, page_matches_version, + parse_badge_expiry, parse_versions_config, process_version_fences, ) @@ -639,3 +643,189 @@ def test_prerelease_and_eol_flags(self): # Non-flagged version should not have the keys assert "prerelease" not in result["versions"][1] assert "eol" not in result["versions"][1] + + +# --------------------------------------------------------------------------- +# parse_badge_expiry +# --------------------------------------------------------------------------- + + +class TestParseBadgeExpiry: + def test_none(self): + assert parse_badge_expiry(None) is BADGE_EXPIRY_NEVER + + def test_never_string(self): + result = parse_badge_expiry("never") + assert result.mode == "never" + + def test_never_case_insensitive(self): + assert parse_badge_expiry("Never").mode == "never" + + def test_releases(self): + result = parse_badge_expiry("3 releases") + assert result.mode == "releases" + assert result.value == 3 + + def test_release_singular(self): + result = parse_badge_expiry("1 release") + assert result.mode == "releases" + assert result.value == 1 + + def test_minor_releases(self): + result = parse_badge_expiry("2 minor releases") + assert result.mode == "minor_releases" + assert result.value == 2 + + def test_days(self): + result = parse_badge_expiry("180 days") + assert result.mode == "days" + assert result.value == 180 + + def test_day_singular(self): + result = parse_badge_expiry("1 day") + assert result.mode == "days" + assert result.value == 1 + + def test_iso_date(self): + result = parse_badge_expiry("2026-06-01") + assert result.mode == "date" + assert result.value == "2026-06-01" + + def test_version_tag(self): + result = parse_badge_expiry("0.8") + assert result.mode == "version" + assert result.value == "0.8" + + def test_version_tag_with_v_prefix(self): + result = parse_badge_expiry("v1.2") + assert result.mode == "version" + assert result.value == "v1.2" + + +# --------------------------------------------------------------------------- +# is_badge_expired +# --------------------------------------------------------------------------- + + +class TestIsBadgeExpired: + @pytest.fixture + def versions(self) -> list[VersionEntry]: + return parse_versions_config( + [ + {"tag": "dev", "label": "dev", "prerelease": True}, + {"tag": "0.7", "label": "0.7.0"}, + {"tag": "0.6", "label": "0.6.0"}, + {"tag": "0.5", "label": "0.5.0"}, + {"tag": "0.4", "label": "0.4.0"}, + {"tag": "0.3", "label": "0.3.0"}, + ] + ) + + def test_never_not_expired(self, versions): + assert is_badge_expired("0.3", versions[1], versions, BADGE_EXPIRY_NEVER) is False + + # --- releases mode --- + + def test_releases_not_expired_same_version(self, versions): + expiry = BadgeExpiry(mode="releases", value=3) + target = versions[5] # 0.3 + assert is_badge_expired("0.3", target, versions, expiry) is False + + def test_releases_not_expired_within_window(self, versions): + expiry = BadgeExpiry(mode="releases", value=3) + target = versions[3] # 0.5 — 2 releases after 0.3 + assert is_badge_expired("0.3", target, versions, expiry) is False + + def test_releases_expired_at_boundary(self, versions): + expiry = BadgeExpiry(mode="releases", value=3) + target = versions[2] # 0.6 — 3 releases after 0.3 + assert is_badge_expired("0.3", target, versions, expiry) is True + + def test_releases_expired_past_boundary(self, versions): + expiry = BadgeExpiry(mode="releases", value=3) + target = versions[1] # 0.7 — 4 releases after 0.3 + assert is_badge_expired("0.3", target, versions, expiry) is True + + # --- minor_releases mode --- + + def test_minor_releases_skips_prerelease(self, versions): + # dev is prerelease, so only 0.7-0.3 count + expiry = BadgeExpiry(mode="minor_releases", value=3) + target = versions[2] # 0.6 — 3 non-pre releases after 0.3 + assert is_badge_expired("0.3", target, versions, expiry) is True + + def test_minor_releases_not_expired(self, versions): + expiry = BadgeExpiry(mode="minor_releases", value=3) + target = versions[3] # 0.5 — 2 non-pre releases after 0.3 + assert is_badge_expired("0.3", target, versions, expiry) is False + + def test_minor_releases_prerelease_target_falls_back_to_latest(self, versions): + # dev (prerelease) should behave like the latest non-prerelease (0.7) + expiry = BadgeExpiry(mode="minor_releases", value=3) + target_dev = versions[0] # dev + target_07 = versions[1] # 0.7 + assert is_badge_expired("0.3", target_dev, versions, expiry) == is_badge_expired( + "0.3", target_07, versions, expiry + ) + + # --- version mode --- + + def test_version_not_expired_before_threshold(self, versions): + expiry = BadgeExpiry(mode="version", value="0.6") + target = versions[3] # 0.5 + assert is_badge_expired("0.3", target, versions, expiry) is False + + def test_version_expired_at_threshold(self, versions): + expiry = BadgeExpiry(mode="version", value="0.6") + target = versions[2] # 0.6 + assert is_badge_expired("0.3", target, versions, expiry) is True + + def test_version_expired_after_threshold(self, versions): + expiry = BadgeExpiry(mode="version", value="0.6") + target = versions[1] # 0.7 + assert is_badge_expired("0.3", target, versions, expiry) is True + + # --- date mode --- + + def test_date_not_expired_future(self, versions): + expiry = BadgeExpiry(mode="date", value="2099-01-01") + assert is_badge_expired("0.3", versions[1], versions, expiry) is False + + def test_date_expired_past(self, versions): + expiry = BadgeExpiry(mode="date", value="2020-01-01") + assert is_badge_expired("0.3", versions[1], versions, expiry) is True + + # --- days mode --- + + def test_days_no_released_date(self, versions): + expiry = BadgeExpiry(mode="days", value=90) + # No released date → fail open + assert is_badge_expired("0.3", versions[1], versions, expiry) is False + + def test_days_expired(self): + versions = parse_versions_config( + [ + {"tag": "0.5", "label": "0.5.0"}, + {"tag": "0.3", "label": "0.3.0", "released": "2020-01-01"}, + ] + ) + expiry = BadgeExpiry(mode="days", value=90) + assert is_badge_expired("0.3", versions[0], versions, expiry) is True + + def test_days_not_expired(self): + versions = parse_versions_config( + [ + {"tag": "0.5", "label": "0.5.0"}, + {"tag": "0.3", "label": "0.3.0", "released": "2099-01-01"}, + ] + ) + expiry = BadgeExpiry(mode="days", value=90) + assert is_badge_expired("0.3", versions[0], versions, expiry) is False + + # --- unknown badge version --- + + def test_unknown_badge_version(self, versions): + expiry = BadgeExpiry(mode="releases", value=1) + assert is_badge_expired("9.9", versions[1], versions, expiry) is False + + # --- changed/deprecated not affected (tested via expand_version_badges) --- diff --git a/user_guide/28-multi-version-docs.qmd b/user_guide/28-multi-version-docs.qmd index df83fdcd..a5e61740 100644 --- a/user_guide/28-multi-version-docs.qmd +++ b/user_guide/28-multi-version-docs.qmd @@ -85,6 +85,7 @@ versions: | `eol` | `bool` | `false` | Show an end-of-life warning banner | | `api_snapshot` | `str` | `None` | Path to a pre-generated API snapshot JSON | | `git_ref` | `str` | `None` | Git tag for API introspection (tags only) | +| `released` | `str` | `None` | ISO date (e.g. `"2025-03-15"`) when this version was released. Used by the `days` badge expiry mode | When strings are used instead of dicts, `tag` and `label` both default to the string value. Most projects only need `tag`, `label`, and occasionally `api_snapshot`. The other fields are useful when you have pre-release or end-of-life versions to distinguish. @@ -141,7 +142,7 @@ This content appears in every version except 0.1. ### Version Expressions -Both `.version-only` and `.version-except` accept version expressions — flexible patterns that target one or more versions. Here's the full set of supported expressions: +Both `.version-only` and `.version-except` accept version expressions: flexible patterns that target one or more versions. Here's the full set of supported expressions: | Expression | Meaning | |---|---| @@ -169,7 +170,7 @@ This extra detail only appears in 0.3 and later. ::: ``` -In version 0.2, only the outer content appears. In version 0.3+, both blocks are visible. In version 0.1, neither appears. This is useful for progressive disclosure — adding detail in newer versions without duplicating the surrounding context. +In version 0.2, only the outer content appears. In version 0.3+, both blocks are visible. In version 0.1, neither appears. This is useful for progressive disclosure, adding detail in newer versions without duplicating the surrounding context. ## Page-Level Version Scoping @@ -193,7 +194,7 @@ versions: ["0.1", "0.2"] ### Version Expressions in Frontmatter -Instead of listing every matching version explicitly, you can use the same version expression syntax that fences support. This is especially useful for pages that apply to a version "and everything newer" — you don't need to update the frontmatter each time a new version is released: +Instead of listing every matching version explicitly, you can use the same version expression syntax that fences support. This is especially useful for pages that apply to a version "and everything newer" so you don't need to update the frontmatter each time a new version is released: ```{.yaml filename="new-feature.qmd"} --- @@ -218,7 +219,7 @@ All expression types work: Use `versions: ">=0.5"` instead of `versions: ["0.5", "0.6", "0.7", "dev"]`. The expression form automatically includes future versions so you never need to update frontmatter when a new version is released. ::: -Pages with no `versions` key appear in **all** versions. Scoped pages are excluded from the sidebar, search index, and build output for non-matching versions. This keeps the navigation clean — users never see links to pages that don't exist in their version. +Pages with no `versions` key appear in **all** versions. Scoped pages are excluded from the sidebar, search index, and build output for non-matching versions. This keeps the navigation clean and users never see links to pages that don't exist in their version. ## Section-Level Version Scoping @@ -266,6 +267,75 @@ These expand into styled `` badges: Badges are compact enough to use liberally throughout your API reference and user guide pages. They give readers an at-a-glance sense of what's new without interrupting the flow of the documentation. +### Badge Expiry + +As your project matures, "new" badges from several releases ago become noise rather than signal. The `new_is_old` option lets you automatically suppress old `new` badges so they stop rendering after a configurable threshold. Only `new` badges are affected while `changed` and `deprecated` badges always render. + +Add `new_is_old` to your `great-docs.yml`: + +```{.yaml filename="great-docs.yml"} +new_is_old: 3 releases +``` + +With this setting, a `[version-badge new 0.3]` badge will render in versions 0.3 through 0.5 (three releases) and then silently disappear from version 0.6 onward. The badge is simply omitted from the output (no placeholder or residual markup is left behind). + +#### Expiry Modes + +| Value | Meaning | +|---|---| +| `"never"` | Badges never expire (the default) | +| `"3 releases"` | Expire after 3 releases, counting all versions including prerelease | +| `"2 minor releases"` | Expire after 2 releases, counting only non-prerelease versions | +| `"0.6"` | Expire starting at version 0.6 (all `new` badges from before 0.6 are suppressed) | +| `"2026-06-01"` | Expire after an absolute calendar date | +| `"180 days"` | Expire 180 days after the badge's version was released (requires `released` dates in the version config) | + +The `releases` and `minor releases` modes measure the distance between the badge's version and the version currently being built. For example, `3 releases` means "show this badge as long as the version being built is fewer than 3 releases newer than the badge's version." The `minor releases` variant is identical except that it skips prerelease entries when counting, so a `dev` prerelease doesn't consume a slot in the window. + +::: {.callout-tip} +## Choosing a mode +Most projects should start with `"3 releases"` or `"2 minor releases"`. The release-counting modes are simple, predictable, and don't require any extra configuration. Use `days` only if your releases are irregular and you want time-based expiry. +::: + +#### Days Mode and Release Dates + +The `days` mode requires `released` dates in your version configuration so that Great Docs can compute elapsed time: + +```{.yaml filename="great-docs.yml"} +new_is_old: 180 days + +versions: + - label: "0.7.0" + tag: "0.7" + latest: true + released: "2026-01-15" + - label: "0.6.0" + tag: "0.6" + released: "2025-09-01" + - label: "0.5.0" + tag: "0.5" + released: "2025-03-15" +``` + +A badge for a version without a `released` date is never expired (fail-open), so you can add dates incrementally. + +#### Per-Page Override + +You can override the global `new_is_old` setting on individual pages by adding `new-is-old` to the page's YAML frontmatter: + +```{.yaml filename="important-feature.qmd"} +--- +title: "Core Feature" +new-is-old: never +--- +``` + +This keeps all `new` badges visible on that page regardless of the global setting. The per-page value uses the same syntax as the global option (any expiry mode works). + +#### Interaction with Fences + +Badge expiry only affects **rendering** (whether the badge `` appears in the HTML output). It does not change version fence behavior. A heading like `## Widget [version-badge new 0.3]` inside a `::: {.version-only versions=">=0.3"}` fence will still be included in matching versions even if the badge itself is suppressed. The fence controls structural inclusion; the badge is purely visual. + ## Version Callouts When a version change needs more explanation than a badge can provide, use a version callout. These render as styled admonition boxes that draw the reader's attention to important changes. @@ -291,7 +361,7 @@ Use callouts for migration notes, deprecation notices, or any change that benefi ## API Reference Versioning -The API reference is the most complex part of versioned documentation because the public API surface — classes, functions, parameters, and signatures — changes between releases. Great Docs supports three strategies for keeping each version's reference accurate, from the most hands-on to the most automated. +The API reference is the most complex part of versioned documentation because the public API surface (classes, functions, parameters, and signatures) changes between releases. Great Docs supports three strategies for keeping each version's reference accurate, from the most hands-on to the most automated. ### Strategy A: Pre-Written Snapshots