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
55 changes: 40 additions & 15 deletions scripts/gen_landing.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@
# record's `source_date_epoch`, else an em dash — never a filesystem timestamp — so
# a second render of the same inputs is byte-identical (a NOOP republish never
# manufactures a commit) — except that the page footer carries the render year, so
# the first render of each new year rewrites every page once.
# the first render of each new year rewrites every page once. The footer's
# `Site updated` time is the newest package Published epoch, so it moves only on a publish.
#
# Stdlib only + the `zstd` binary (to read a .pkg's +COMPACT_MANIFEST). Dev-only
# tooling — not shipped in release archives (those contain only src/).
Expand Down Expand Up @@ -537,9 +538,20 @@ def _site_header(root: str) -> str:
)


def _site_footer(root: str) -> str:
def _site_footer(root: str, updated: float | None = None) -> str:
"""The main site's footer. The year is the render year: the one render after
New Year changes the page, every other re-render stays byte-identical."""
New Year changes the page, every other re-render stays byte-identical.
``updated`` is the newest package publish epoch in the tree (issue #3485), never the
render clock, so a re-render without a publish stays byte-identical."""
# Same text and datetime format as the main site's footer (site/dist), so both sites match.
site_updated = (
" · Site updated "
+ f'<time class="site-updated" datetime="{datetime.fromtimestamp(updated, tz=timezone.utc).isoformat()}">'
+ datetime.fromtimestamp(updated, tz=timezone.utc).strftime("%-d %b %Y, %H:%M UTC")
+ "</time>"
if updated is not None
else ""
)
year = datetime.now(timezone.utc).year
return (
'<footer class="foot"><div class="wrap">'
Expand All @@ -554,7 +566,7 @@ def _site_footer(root: str) -> str:
'<a href="https://x.com/bbcan177" target="_blank" rel="noopener">X @bbcan177</a><a href="https://x.com/andrebrait" target="_blank" rel="noopener">X @andrebrait</a>'
f'<a href="{MAIN_SITE_URL}/beta/">v4 beta</a><a href="{MAIN_SITE_URL}/rss/">RSS</a>'
'<a href="mailto:contact@pfblockerng.com">contact@pfblockerng.com</a></nav>'
f'<div class="foot-legal"><span>© {year} pfBlockerNG. All rights reserved.</span>'
f'<div class="foot-legal"><span>© {year} pfBlockerNG. All rights reserved.{site_updated}</span>'
'<span>Licensed under <a href="https://www.apache.org/licenses/LICENSE-2.0" target="_blank" rel="noopener">Apache 2.0</a>, provided "as is", '
"without warranties or conditions of any kind. pfSense is a trademark of its owner.</span>"
'<span>pkg.pfblockerng.com is hosted on GitHub Pages from the public '
Expand Down Expand Up @@ -609,10 +621,16 @@ def _head(title: str, root: str, desc: str, canonical_path: str) -> str:

_LOCAL_TIME_JS = (
"(function(){function p(n){return String(n).padStart(2,'0');}"
"document.querySelectorAll('time[datetime]').forEach(function(t){"
"document.querySelectorAll('time[datetime]:not(.site-updated)').forEach(function(t){"
"var d=new Date(t.dateTime);if(Number.isNaN(d.getTime()))return;"
"t.textContent=d.getFullYear()+'-'+p(d.getMonth()+1)+'-'+p(d.getDate())+' '+p(d.getHours())+':'+p(d.getMinutes());"
"});})();"
# Footer: identical to the main site's site.js handler, zone label included.
"(window.requestIdleCallback||function(f){setTimeout(f,200);})(function(){"
"document.querySelectorAll('time.site-updated').forEach(function(t){"
"var d=new Date(t.getAttribute('datetime'));"
"if(!isNaN(d))t.textContent=d.toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',"
"hour:'2-digit',minute:'2-digit',timeZoneName:'short'});});});"
)


Expand Down Expand Up @@ -1229,6 +1247,7 @@ def render_page(
conf_fn: Callable[[str], str],
site_tree: dict[str, tuple[bytes, int]],
matrix: list[dict] | None = None,
updated: float | None = None,
) -> str:
"""Render the root landing page. ``site_tree`` is the BUILT site tree (issue
#2450) — the source of each published channel's recipe text."""
Expand Down Expand Up @@ -1257,7 +1276,7 @@ def render_page(
'<p class="blurb">Browse every channel, version and ABI &mdash; and the raw pkg(8) catalogs your '
"firewall fetches &mdash; in a directory-style listing.</p>"
'<p><a class="browse" href="./browse.html">&#128193; Browse the repository &rarr;</a></p></section>'
f"</main>{_site_footer('./')}<script>{_LOCAL_TIME_JS}{_COPY_JS}</script></body></html>\n"
f"</main>{_site_footer('./', updated)}<script>{_LOCAL_TIME_JS}{_COPY_JS}</script></body></html>\n"
)


Expand All @@ -1267,7 +1286,9 @@ def _epoch_cell(epoch: float | None) -> str:
return _time_html(epoch) if epoch is not None else _or_dash("")


def _render_listing_html(title: str, home_href: str, rows: list[str], canonical_path: str) -> str:
def _render_listing_html(
title: str, home_href: str, rows: list[str], canonical_path: str, updated: float | None = None
) -> str:
"""The shared directory-listing page shell — the landing/browse chrome around a
Name | Last modified | Size table, used by both browse.html and every
browse/<ch>/… page."""
Expand All @@ -1282,7 +1303,7 @@ def _render_listing_html(title: str, home_href: str, rows: list[str], canonical_
f"<tbody>{''.join(rows)}</tbody></table></div>"
f'<p class="listing-note">{_LISTING_DESC} '
"pkg(8) fetches the catalog files (<code>meta.conf</code>, <code>packagesite.pkg</code>, …) directly.</p>"
f"</section></main>{_site_footer(home_href)}<script>{_LOCAL_TIME_JS}</script></body></html>\n"
f"</section></main>{_site_footer(home_href, updated)}<script>{_LOCAL_TIME_JS}</script></body></html>\n"
)


Expand All @@ -1304,7 +1325,7 @@ def _root_files(built: dict[str, tuple[bytes, int]]) -> list[str]:
return sorted(name for name in built if "/" not in name and name not in hidden)


def render_browse_root(docs: str, built: dict[str, tuple[bytes, int]]) -> str:
def render_browse_root(docs: str, built: dict[str, tuple[bytes, int]], updated: float | None = None) -> str:
"""docs/browse.html — the top-level entry point: one row per present catalogue
channel (CH_ORDER order; ``staging`` is never listed, issue #2389) linking into
its own browse/<ch>/ page, then every root-level site-tree file linking directly.
Expand All @@ -1323,7 +1344,7 @@ def render_browse_root(docs: str, built: dict[str, tuple[bytes, int]]) -> str:
f'<td class="num">{_epoch_cell(None)}</td>'
f'<td class="num">{_esc(human_size(len(data)))}</td></tr>'
)
return _render_listing_html("/", "./", rows, "browse.html")
return _render_listing_html("/", "./", rows, "browse.html", updated)


def _catalogue_subdirs(docs: str, channel: str) -> list[str]:
Expand All @@ -1338,7 +1359,7 @@ def _catalogue_subdirs(docs: str, channel: str) -> list[str]:
return sorted(out)


def _render_catalogue_browse_page(docs: str, full_rel: str) -> str:
def _render_catalogue_browse_page(docs: str, full_rel: str, updated: float | None = None) -> str:
"""One ``browse/<full_rel>/index.html`` page — ``full_rel`` is ``"<channel>"``
or ``"<channel>/<sub>/…"``, a real directory under docs/. Subdirs link within
the browse mirror (``./<name>/``); files link OUT into the real catalogue tree
Expand Down Expand Up @@ -1377,7 +1398,7 @@ def _render_catalogue_browse_page(docs: str, full_rel: str) -> str:
f'<td class="num">{_epoch_cell(_display_epoch(path))}</td>'
f'<td class="num">{_esc(human_size(os.path.getsize(path)))}</td></tr>'
)
return _render_listing_html(f"/{full_rel}", climb, rows, f"browse/{full_rel}/")
return _render_listing_html(f"/{full_rel}", climb, rows, f"browse/{full_rel}/", updated)


# The exact hardcoded PFB_BASE_URL default line install.sh's repository copy
Expand Down Expand Up @@ -1650,18 +1671,22 @@ def write_site(
def conf_fn(channel: str) -> str:
return _render_conf(base, channel)

epochs = [r["published_epoch"] for r in pkgs if r.get("published_epoch") is not None]
# Newest package build/source epoch (the same value as the "Published" column), not the
# clock; it can precede the actual publish by a few hours (issue #3485).
updated = max(epochs, default=None)
desired = dict(built)
# A rendered page always wins over a same-named site-tree file (H4, issue #2450).
desired["index.html"] = (
render_page(base, pkgs, conf_fn, built, matrix).encode(),
render_page(base, pkgs, conf_fn, built, matrix, updated).encode(),
0o644,
)
desired["browse.html"] = (render_browse_root(site, built).encode(), 0o644)
desired["browse.html"] = (render_browse_root(site, built, updated).encode(), 0o644)
for ch in CH_ORDER:
if not os.path.isdir(os.path.join(site, ch)):
continue
for full_rel in _catalogue_subdirs(site, ch):
page = _render_catalogue_browse_page(site, full_rel)
page = _render_catalogue_browse_page(site, full_rel, updated)
desired[f"browse/{full_rel}/index.html"] = (page.encode(), 0o644)

written, deleted = sync_site(site, desired)
Expand Down
49 changes: 49 additions & 0 deletions tests/test_gen_landing.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import stat
import subprocess
import tarfile
import time
import urllib.parse
from datetime import datetime, timezone
from pathlib import Path
Expand Down Expand Up @@ -199,6 +200,7 @@ def test_timestamp_html_keeps_utc_instant_and_localizes_in_the_browser(tmp_path:

runner = (
'var t={dateTime:"2026-08-14T19:32:17Z",textContent:"UTC fallback"};'
"global.window={requestIdleCallback:function(){}};"
"global.document={querySelectorAll:function(){return [t];}};"
f"{gl._LOCAL_TIME_JS}"
"process.stdout.write(t.textContent);"
Expand Down Expand Up @@ -3422,6 +3424,53 @@ def test_rendered_footer_matches_the_main_site_footer(tmp_path: Path, monkeypatc
) in foot, page


def _two_publish_site(tmp_path: Path, monkeypatch: Any) -> Path:
"""A stable + a testing package whose manifests carry different `created` epochs."""
site = tmp_path / "site"
epochs = {"stable": 1_700_000_000, "testing": 1_760_000_000} # newest: 2025-10-09 08:53:20 UTC
for ch in epochs:
_touch(site / ch / "ce-2.8" / "FreeBSD:15:*" / f"{_CANON}-3.2.16.pkg")

def manifest(path: str) -> dict:
ch = "testing" if "/testing/" in str(path) else "stable"
return {"name": _CANON, "version": "3.2.16", "abi": "FreeBSD:15:*", "annotations": {"created": str(epochs[ch])}}

monkeypatch.setattr(gl, "read_compact_manifest", manifest)
monkeypatch.setattr(gl, "_render_conf", lambda base, ch: f"{ch}-conf")
gl.write_site(str(site), "https://pkg.pfblockerng.com/", str(_PKG_SITE_DIR))
return site


def test_footer_site_updated_is_the_newest_publish_time(tmp_path: Path, monkeypatch: Any) -> None:
"""Issue #3485: every page's footer shows ' · Site updated <time class="site-updated">' with
the newest package publish time in the tree, in the main site's footer format."""
site = _two_publish_site(tmp_path, monkeypatch)
want = (
'· Site updated <time class="site-updated" datetime="2025-10-09T08:53:20+00:00">'
"9 Oct 2025, 08:53 UTC</time></span>"
)
for page in _rendered_pages(site):
text = page.read_text()
assert want in text, page
# N2: the in-browser rewrite keeps a zone label, like the main site's site.js,
# and the table-time rewrite leaves the footer alone.
assert "time.site-updated" in text and "timeZoneName:'short'" in text, page
assert "time[datetime]:not(.site-updated)" in text, page


def test_rerender_of_an_unchanged_tree_is_byte_identical(tmp_path: Path, monkeypatch: Any) -> None:
"""Issue #3485: the footer time comes from the data, never the clock, so a re-render
of the same tree rewrites nothing."""
site = _two_publish_site(tmp_path, monkeypatch)
first = {p: p.read_bytes() for p in _rendered_pages(site)}
assert all(b"Site updated" in b for b in first.values())
# A clock-derived footer must differ: move the clock a year on between the two renders.
now = time.time()
monkeypatch.setattr(time, "time", lambda: now + 366 * 86400)
gl.write_site(str(site), "https://pkg.pfblockerng.com/", str(_PKG_SITE_DIR))
assert {p: p.read_bytes() for p in _rendered_pages(site)} == first


MAIN_MENU = [
("Features", "https://pfblockerng.com/#features"),
("Install", "https://pfblockerng.com/#install"),
Expand Down
Loading