Skip to content

Commit c727d2c

Browse files
authored
Merge pull request #119 from posit-dev/enh-badge-expiry
enh: add badge expiry functionality
2 parents 3aa60ac + 827c3b3 commit c727d2c

6 files changed

Lines changed: 532 additions & 13 deletions

File tree

great_docs/_versioned_build.py

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,24 @@ def _collect_qmd_files(source_dir: Path) -> list[Path]:
4242
return sorted(files)
4343

4444

45+
_FRONTMATTER_VALUE_RE = _re.compile(r"^---\s*\n(.*?)\n---\s*\n", _re.DOTALL)
46+
47+
48+
def _extract_frontmatter_value(content: str, key: str) -> str | None:
49+
"""Extract a scalar value for *key* from YAML frontmatter, or `None`."""
50+
m = _FRONTMATTER_VALUE_RE.match(content)
51+
if not m:
52+
return None
53+
fm = m.group(1)
54+
# Simple line-based extraction that handles `key: value` and `key: "value"`
55+
pattern = _re.compile(rf"^{_re.escape(key)}\s*:\s*(.+)$", _re.MULTILINE)
56+
km = pattern.search(fm)
57+
if not km:
58+
return None
59+
val = km.group(1).strip().strip('"').strip("'")
60+
return val
61+
62+
4563
def _prune_cli_pages(dest_dir: Path, snap: object) -> None:
4664
"""
4765
Remove CLI reference QMD files for commands not in the snapshot.
@@ -256,6 +274,7 @@ def preprocess_version(
256274
all_versions: list[VersionEntry],
257275
project_root: Path | None = None,
258276
section_configs: list[dict] | None = None,
277+
badge_expiry: "BadgeExpiry | None" = None,
259278
) -> list[str]:
260279
"""
261280
Preprocess the documentation source for a single version.
@@ -351,7 +370,16 @@ def preprocess_version(
351370
# 6. Expand inline [version-badge] markers and version callouts
352371
for qmd_file in _collect_qmd_files(dest_dir):
353372
content = qmd_file.read_text(encoding="utf-8", errors="replace")
354-
updated = expand_version_badges(content, entry)
373+
374+
# Per-page new-is-old override
375+
page_expiry = badge_expiry
376+
page_override = _extract_frontmatter_value(content, "new-is-old")
377+
if page_override is not None:
378+
from great_docs._versioning import parse_badge_expiry
379+
380+
page_expiry = parse_badge_expiry(page_override)
381+
382+
updated = expand_version_badges(content, entry, all_versions, page_expiry)
355383
updated = expand_version_callouts(updated, entry)
356384
if updated != content:
357385
qmd_file.write_text(updated, encoding="utf-8")
@@ -802,30 +830,50 @@ def _rebuild_api_from_git_ref(
802830
)
803831

804832

805-
def expand_version_badges(content: str, entry: VersionEntry) -> str:
833+
def expand_version_badges(
834+
content: str,
835+
entry: VersionEntry,
836+
versions: list[VersionEntry] | None = None,
837+
expiry: "BadgeExpiry | None" = None,
838+
) -> str:
806839
"""
807840
Expand `[version-badge new]` and `[version-badge changed 0.3]` inline markers into HTML
808841
`<span>` badges.
809842
810843
If no version is specified in the marker, the current entry's label is used.
811844
845+
When *expiry* is provided and a `new` badge is expired, the marker is removed entirely (no HTML
846+
emitted). `changed` and `deprecated` badges are never affected by expiry.
847+
812848
Parameters
813849
----------
814850
content
815851
The `.qmd` file content.
816852
entry
817853
The version being built.
854+
versions
855+
The full ordered list of version entries (needed for expiry evaluation).
856+
expiry
857+
Badge expiry policy. `None` means never expire.
818858
819859
Returns
820860
-------
821861
str
822862
Content with markers replaced by HTML spans.
823863
"""
864+
from great_docs._versioning import BADGE_EXPIRY_NEVER, is_badge_expired
865+
866+
effective_expiry = expiry or BADGE_EXPIRY_NEVER
824867

825868
def _replace(m: _re.Match) -> str:
826869
badge_type = m.group(1).lower()
827870
version = m.group(2) or entry.label
828871

872+
# Check expiry for "new" badges only
873+
if badge_type == "new" and versions and effective_expiry.mode != "never":
874+
if is_badge_expired(version, entry, versions, effective_expiry):
875+
return ""
876+
829877
css_class = f"gd-badge gd-badge-{badge_type}"
830878
if badge_type == "new":
831879
label = f"New in {version}"
@@ -1274,6 +1322,7 @@ def run_versioned_build(
12741322
site_url: str | None = None,
12751323
progress_callback: Callable[[int, int, int], None] | None = None,
12761324
on_renders_done: Callable[[], None] | None = None,
1325+
badge_expiry_raw: str | None = None,
12771326
) -> dict[str, Any]:
12781327
"""
12791328
Orchestrate a full multi-version build.
@@ -1315,6 +1364,11 @@ def run_versioned_build(
13151364
latest = get_latest_version(versions)
13161365
latest_tag = latest.tag if latest else versions[0].tag
13171366

1367+
# Parse badge expiry config
1368+
from great_docs._versioning import parse_badge_expiry
1369+
1370+
badge_expiry = parse_badge_expiry(badge_expiry_raw)
1371+
13181372
# Filter versions based on CLI flags
13191373
if latest_only:
13201374
targets = [v for v in versions if v.tag == latest_tag]
@@ -1349,6 +1403,7 @@ def run_versioned_build(
13491403
entry,
13501404
versions,
13511405
project_root=project_root,
1406+
badge_expiry=badge_expiry,
13521407
)
13531408
_prune_missing_sidebar_pages(ver_dir)
13541409
_rewrite_quarto_yml_for_version(ver_dir, entry, latest_tag, site_url=site_url)

great_docs/_versioning.py

Lines changed: 161 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import re
44
from dataclasses import dataclass, field
5+
from datetime import date
56
from 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+
2584
def 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

223375
def 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
-------

great_docs/core.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13008,6 +13008,7 @@ def _on_renders_done() -> None:
1300813008
latest_only=latest_only,
1300913009
progress_callback=_progress_cb,
1301013010
on_renders_done=_on_renders_done,
13011+
badge_expiry_raw=self._config.get("new_is_old"),
1301113012
)
1301213013

1301313014
if not vb_result["success"]:

0 commit comments

Comments
 (0)