From 99d6ffba7c8931c50d948c4f8dc489010ac4ab0a Mon Sep 17 00:00:00 2001 From: Sean N Date: Thu, 16 Apr 2026 12:10:16 +0200 Subject: [PATCH] Fix fingerprint stacking on incremental builds (#11) --- pyproject.toml | 4 +-- src/nitro/core/bundler.py | 6 +++++ tests/test_bundler.py | 51 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6929c69..e9275a3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "nitro-cli" -version = "1.0.12" +version = "1.0.13" description = "Build static sites with Python code instead of template engines" authors = [ {name = "Sean Nieuwoudt", email = "sean@nitro.sh"} @@ -49,13 +49,13 @@ dependencies = [ "nitro-datastore>=1.0.2", "nitro-dispatch>=1.0.0", "beautifulsoup4>=4.12.0", + "html5lib>=1.1", ] [project.optional-dependencies] dev = [ "pytest>=8.0.0", "pytest-cov>=5.0.0", - "html5lib>=1.1", "black>=24.0.0", "flake8>=7.0.0", ] diff --git a/src/nitro/core/bundler.py b/src/nitro/core/bundler.py index 9eeb9be..5138320 100644 --- a/src/nitro/core/bundler.py +++ b/src/nitro/core/bundler.py @@ -233,6 +233,9 @@ def create_asset_manifest(self, output_path: Path) -> None: except (IOError, OSError) as e: error(f"Failed to write asset manifest: {e}") + # Matches stems that already end with an 8-char hex fingerprint (e.g. "nav.36da3320") + _FINGERPRINT_RE = re.compile(r"\.[0-9a-f]{8}$") + def fingerprint_assets(self) -> Dict[str, str]: """Add content hashes to CSS and JS filenames for cache busting.""" asset_files = [] @@ -245,6 +248,9 @@ def fingerprint_assets(self) -> Dict[str, str]: path_mapping = {} for asset_path in asset_files: + # Skip files already fingerprinted from a previous build + if self._FINGERPRINT_RE.search(asset_path.stem): + continue content = asset_path.read_bytes() hasher = hashlib.sha256() hasher.update(content) diff --git a/tests/test_bundler.py b/tests/test_bundler.py index 129e148..755f2cf 100644 --- a/tests/test_bundler.py +++ b/tests/test_bundler.py @@ -262,6 +262,57 @@ def test_updates_html_references(self): new_css_name = Path(mapping["style.css"]).name assert new_css_name in html_content + def test_skips_already_fingerprinted_files(self): + """Should not re-fingerprint files from a previous build (issue #11).""" + with tempfile.TemporaryDirectory() as tmpdir: + build_dir = Path(tmpdir) + + # Simulate state after a previous build: both the fresh source + # file and the old fingerprinted copy exist in build/ + js_content = "console.log('nav');" + (build_dir / "nav.js").write_text(js_content) + (build_dir / "nav.36da3320.js").write_text(js_content) + + bundler = Bundler(build_dir) + mapping = bundler.fingerprint_assets() + + # Only the fresh file should be fingerprinted + assert "nav.js" in mapping + assert "nav.36da3320.js" not in mapping + + # No double-hashed files should exist + for f in build_dir.iterdir(): + # Count dots in stem — a stacked hash would have 2+ dots + assert f.stem.count(".") <= 1, f"Stacked fingerprint: {f.name}" + + def test_incremental_build_no_hash_stacking(self): + """Running fingerprint_assets twice should not stack hashes.""" + with tempfile.TemporaryDirectory() as tmpdir: + build_dir = Path(tmpdir) + + css_content = "body { color: blue; }" + (build_dir / "main.css").write_text(css_content) + + bundler = Bundler(build_dir) + + # First fingerprint + mapping1 = bundler.fingerprint_assets() + first_result = mapping1["main.css"] + + # Simulate incremental build: fresh source file reappears + (build_dir / "main.css").write_text(css_content) + + # Second fingerprint + mapping2 = bundler.fingerprint_assets() + + assert "main.css" in mapping2 + assert mapping2["main.css"] == first_result + + # Only fingerprinted files and the newly renamed file should exist + css_files = list(build_dir.glob("*.css")) + # Should not have more than 1 CSS file (the fingerprinted one) + assert len(css_files) <= 1, f"Expected 1 CSS file, got: {[f.name for f in css_files]}" + class TestSitemapCleanUrls: """Tests for clean_urls option in generate_sitemap."""