22
33import re
44from dataclasses import dataclass , field
5+ from datetime import date
56from typing import Any
67
78
@@ -16,12 +17,70 @@ class VersionEntry:
1617 eol : bool = False
1718 api_snapshot : str | None = None
1819 git_ref : str | None = None
20+ released : str | None = None
1921
2022 # Positional index in the versions list (0 = newest).
2123 # Set by parse_versions_config after construction.
2224 _index : int = field (default = 0 , repr = False )
2325
2426
27+ @dataclass
28+ class BadgeExpiry :
29+ """Controls when 'new' badges stop rendering."""
30+
31+ mode : str # "releases" | "minor_releases" | "version" | "date" | "days" | "never"
32+ value : int | str = 0 # count, version tag, ISO date string, or day count
33+
34+
35+ # Sentinel for "never expire"
36+ BADGE_EXPIRY_NEVER = BadgeExpiry (mode = "never" )
37+
38+
39+ _BADGE_EXPIRY_RE = re .compile (r"^(\d+)\s+(releases?|minor\s+releases?)$" , re .IGNORECASE )
40+ _BADGE_EXPIRY_DAYS_RE = re .compile (r"^(\d+)\s+days?$" , re .IGNORECASE )
41+ _BADGE_EXPIRY_DATE_RE = re .compile (r"^\d{4}-\d{2}-\d{2}$" )
42+
43+
44+ def parse_badge_expiry (raw : str | None ) -> BadgeExpiry :
45+ """
46+ Parse a `new_is_old` value into a `BadgeExpiry`.
47+
48+ Accepted forms::
49+
50+ "never" → BadgeExpiry("never")
51+ "3 releases" → BadgeExpiry("releases", 3)
52+ "2 minor releases" → BadgeExpiry("minor_releases", 2)
53+ "0.8" → BadgeExpiry("version", "0.8")
54+ "2026-06-01" → BadgeExpiry("date", "2026-06-01")
55+ "180 days" → BadgeExpiry("days", 180)
56+ """
57+ if raw is None or str (raw ).strip ().lower () == "never" :
58+ return BADGE_EXPIRY_NEVER
59+
60+ raw = str (raw ).strip ()
61+
62+ # "3 releases" or "2 minor releases"
63+ m = _BADGE_EXPIRY_RE .match (raw )
64+ if m :
65+ count = int (m .group (1 ))
66+ kind = m .group (2 ).lower ()
67+ if "minor" in kind :
68+ return BadgeExpiry (mode = "minor_releases" , value = count )
69+ return BadgeExpiry (mode = "releases" , value = count )
70+
71+ # "180 days"
72+ m = _BADGE_EXPIRY_DAYS_RE .match (raw )
73+ if m :
74+ return BadgeExpiry (mode = "days" , value = int (m .group (1 )))
75+
76+ # "2026-06-01" (ISO date)
77+ if _BADGE_EXPIRY_DATE_RE .match (raw ):
78+ return BadgeExpiry (mode = "date" , value = raw )
79+
80+ # Bare version tag: "0.8", "v1.2", etc.
81+ return BadgeExpiry (mode = "version" , value = raw )
82+
83+
2584def parse_versions_config (raw : list [Any ]) -> list [VersionEntry ]:
2685 """
2786 Parse the `versions:` list from great-docs.yml.
@@ -66,6 +125,7 @@ def parse_versions_config(raw: list[Any]) -> list[VersionEntry]:
66125 eol = bool (item .get ("eol" , False )),
67126 api_snapshot = item .get ("api_snapshot" ),
68127 git_ref = item .get ("git_ref" ),
128+ released = item .get ("released" ),
69129 )
70130 else :
71131 raise ValueError (f"versions[{ i } ]: expected a string or dict, got { type (item ).__name__ } " )
@@ -199,6 +259,100 @@ def evaluate_version_expr(
199259 return True
200260
201261
262+ # ---------------------------------------------------------------------------
263+ # Badge expiry evaluation
264+ # ---------------------------------------------------------------------------
265+
266+
267+ def is_badge_expired (
268+ badge_version : str ,
269+ target_entry : VersionEntry ,
270+ versions : list [VersionEntry ],
271+ expiry : BadgeExpiry ,
272+ ) -> bool :
273+ """
274+ Determine whether a `[version-badge new VERSION]` should be suppressed.
275+
276+ Parameters
277+ ----------
278+ badge_version
279+ The version tag written in the badge (e.g. `"0.5"`).
280+ target_entry
281+ The version currently being built.
282+ versions
283+ The full ordered list of version entries.
284+ expiry
285+ The badge expiry policy.
286+
287+ Returns
288+ -------
289+ bool
290+ `True` if the badge should **not** be rendered (expired).
291+ """
292+ if expiry .mode == "never" :
293+ return False
294+
295+ if expiry .mode == "releases" :
296+ badge_idx = _resolve_index (badge_version , versions )
297+ if badge_idx is None :
298+ return False
299+ distance = badge_idx - target_entry ._index # positive = target is newer
300+ return distance >= int (expiry .value )
301+
302+ if expiry .mode == "minor_releases" :
303+ # Filter out prerelease entries for counting
304+ non_pre = [v for v in versions if not v .prerelease ]
305+ badge_idx = _resolve_index (badge_version , non_pre )
306+ target_idx = _resolve_index (target_entry .tag , non_pre )
307+ # Prerelease target (e.g. dev) isn't in non_pre — fall back to
308+ # the latest non-prerelease so dev expires at least as much as latest.
309+ if target_idx is None and non_pre :
310+ target_idx = non_pre [0 ]._index
311+ if badge_idx is None or target_idx is None :
312+ return False
313+ distance = badge_idx - target_idx
314+ return distance >= int (expiry .value )
315+
316+ if expiry .mode == "version" :
317+ # Expire when building the threshold version or later
318+ threshold_idx = _resolve_index (str (expiry .value ), versions )
319+ if threshold_idx is None :
320+ return False
321+ return target_entry ._index <= threshold_idx
322+
323+ if expiry .mode == "date" :
324+ try :
325+ cutoff = date .fromisoformat (str (expiry .value ))
326+ except ValueError :
327+ return False
328+ return date .today () >= cutoff
329+
330+ if expiry .mode == "days" :
331+ badge_entry = _find_entry (badge_version , versions )
332+ if badge_entry is None or not badge_entry .released :
333+ return False # fail open
334+ try :
335+ released = date .fromisoformat (str (badge_entry .released )[:10 ])
336+ except ValueError :
337+ return False
338+ elapsed = (date .today () - released ).days
339+ return elapsed >= int (expiry .value )
340+
341+ return False
342+
343+
344+ def _find_entry (tag : str , versions : list [VersionEntry ]) -> VersionEntry | None :
345+ """Find a VersionEntry by tag, with v-prefix fallback."""
346+ for v in versions :
347+ if v .tag == tag :
348+ return v
349+ alt = tag [1 :] if tag .startswith ("v" ) else f"v{ tag } "
350+ for v in versions :
351+ if v .tag == alt :
352+ return v
353+ return None
354+
355+
202356# ---------------------------------------------------------------------------
203357# Version fence preprocessing
204358# ---------------------------------------------------------------------------
@@ -215,9 +369,7 @@ def evaluate_version_expr(
215369_HEADING_RE = re .compile (r"^(#{1,6})\s" )
216370
217371# Matches a heading with a [version-badge new VERSION] marker
218- _HEADING_BADGE_NEW_RE = re .compile (
219- r"^(#{1,6})\s+.*\[version-badge\s+new\s+([^\]]+)\]"
220- )
372+ _HEADING_BADGE_NEW_RE = re .compile (r"^(#{1,6})\s+.*\[version-badge\s+new\s+([^\]]+)\]" )
221373
222374
223375def process_version_fences (
@@ -232,7 +384,7 @@ def process_version_fences(
232384 divs. Matching blocks have their fence markers removed (content kept); non-matching blocks are
233385 removed entirely.
234386
235- Headings with `` [version-badge new VERSION]` ` act as implicit section fences: when the target
387+ Headings with `[version-badge new VERSION]` act as implicit section fences: when the target
236388 version is older than VERSION, the heading and all content until the next heading at the same or
237389 higher level are removed. This prevents orphan headings that appear with no content below them.
238390
@@ -291,7 +443,10 @@ def process_version_fences(
291443 i += 1
292444 continue
293445 elif in_code_block :
294- if stripped .startswith (code_fence_pattern ) and stripped .rstrip (code_fence_pattern [0 ]) == "" :
446+ if (
447+ stripped .startswith (code_fence_pattern )
448+ and stripped .rstrip (code_fence_pattern [0 ]) == ""
449+ ):
295450 in_code_block = False
296451 code_fence_pattern = ""
297452 if skip_heading_level == 0 and (not stack or stack [- 1 ][0 ]):
@@ -460,7 +615,7 @@ def page_matches_version(
460615 The version tag being built.
461616 versions
462617 The full ordered list of version entries. When provided, version expressions
463- (e.g. `` ">=0.7"` `) are evaluated; otherwise only bare tag matching is used.
618+ (e.g. `">=0.7"`) are evaluated; otherwise only bare tag matching is used.
464619
465620 Returns
466621 -------
0 commit comments