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
59 changes: 57 additions & 2 deletions great_docs/_versioned_build.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
`<span>` 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}"
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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)
Expand Down
167 changes: 161 additions & 6 deletions great_docs/_versioning.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import re
from dataclasses import dataclass, field
from datetime import date
from typing import Any


Expand All @@ -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.
Expand Down Expand Up @@ -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__}")
Expand Down Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand All @@ -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(
Expand All @@ -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.

Expand Down Expand Up @@ -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]):
Expand Down Expand Up @@ -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
-------
Expand Down
1 change: 1 addition & 0 deletions great_docs/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]:
Expand Down
Loading
Loading