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
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down Expand Up @@ -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",
]
Expand Down
6 changes: 6 additions & 0 deletions src/nitro/core/bundler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand All @@ -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)
Expand Down
51 changes: 51 additions & 0 deletions tests/test_bundler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
Loading