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
2 changes: 1 addition & 1 deletion 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.16"
version = "1.0.17"
description = "Build static sites with Python code instead of template engines"
authors = [
{name = "Sean Nieuwoudt", email = "sean@nitro.sh"}
Expand Down
2 changes: 1 addition & 1 deletion src/nitro/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Nitro CLI - A static site generator"""

__version__ = "1.0.14"
__version__ = "1.0.17"
__author__ = "Sean Nieuwoudt"

from .core.config import Config
Expand Down
7 changes: 2 additions & 5 deletions src/nitro/commands/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
import click

from ..core.bundler import Bundler
from ..core.config import load_config
from ..core.generator import Generator
from ..core.images import ImageOptimizer, ImageConfig
from ..core.islands import IslandProcessor, IslandConfig
Expand Down Expand Up @@ -90,7 +89,7 @@ def build(

verbose(f"Output directory: {generator.build_dir}")

config = load_config(generator.project_root / "nitro.config.py")
config = generator.config
if minify:
config.renderer["minify_html"] = True
generator.renderer.minify_html = True
Expand Down Expand Up @@ -190,13 +189,11 @@ def build(
update("Generating metadata...")
html_files = list(generator.build_dir.rglob("*.html"))
sitemap_path = generator.build_dir / "sitemap.xml"
# Pass page metadata for enhanced sitemap generation
page_metadata = getattr(generator, "page_metadata", None)
bundler.generate_sitemap(
base_url=config.base_url,
html_files=html_files,
output_path=sitemap_path,
page_metadata=page_metadata,
page_metadata=generator.page_metadata,
clean_urls=config.clean_urls,
)
verbose(f"Created sitemap.xml with {len(html_files)} URLs")
Expand Down
7 changes: 3 additions & 4 deletions src/nitro/commands/check.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from typing import List, Tuple

from ..core.page import get_project_root
from ..core.config import load_config
from ..core.config import Config, load_config
from ..core.renderer import Renderer
from ..utils import console, success, error, info, warning

Expand Down Expand Up @@ -52,10 +52,9 @@ def check(verbose, links):
error("Not in a Nitro project directory")
sys.exit(1)

# Load config
config_path = project_root / "nitro.config.py"
config = load_config(config_path) if config_path.exists() else None
source_dir = project_root / (config.source_dir if config else "src")
config = load_config(config_path) if config_path.exists() else Config()
source_dir = project_root / config.source_dir
pages_dir = source_dir / "pages"

if not pages_dir.exists():
Expand Down
7 changes: 3 additions & 4 deletions src/nitro/commands/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from pathlib import Path

from ..core.page import get_project_root
from ..core.config import load_config
from ..core.config import Config, load_config
from ..core.renderer import Renderer
from ..utils import console, error

Expand All @@ -21,10 +21,9 @@ def routes(as_json):
error("Not in a Nitro project directory")
sys.exit(1)

# Load config
config_path = project_root / "nitro.config.py"
config = load_config(config_path) if config_path.exists() else None
source_dir = project_root / (config.source_dir if config else "src")
config = load_config(config_path) if config_path.exists() else Config()
source_dir = project_root / config.source_dir
pages_dir = source_dir / "pages"

if not pages_dir.exists():
Expand Down
35 changes: 22 additions & 13 deletions src/nitro/commands/serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,13 @@ async def serve_async(
loop = asyncio.get_running_loop()
regeneration_lock = asyncio.Lock()

def _is_within(child: Path, parent: Path) -> bool:
try:
child.resolve().relative_to(parent.resolve())
return True
except ValueError:
return False

async def on_file_change(path: Path) -> None:
nonlocal generator

Expand All @@ -110,40 +117,42 @@ async def on_file_change(path: Path) -> None:

hmr_update(relative_path)

# Drop cached src/ modules on any .py change so edits to
# shared components/utils are picked up without restarting.
if path.suffix == ".py":
generator.renderer.invalidate_all_src_modules(
generator.project_root
)

pages_dir = generator.source_dir / "pages"
components_dir = generator.source_dir / "components"
styles_dir = generator.source_dir / "styles"
public_dir = generator.source_dir / "public"

should_notify = False

# Run blocking generator operations in thread pool
if "pages" in str(path):
if path.name == "nitro.config.py":
hmr_update("config", "rebuilding...")
generator = Generator()
if await asyncio.to_thread(
generator.generate, verbose=False, quiet=True
):
should_notify = True
elif _is_within(path, pages_dir):
if path.suffix == ".py" and path.name != "__init__.py":
hmr_update("page", "rebuilding...")
if await asyncio.to_thread(
generator.regenerate_page, path, verbose=False
):
should_notify = True
elif "components" in str(path):
elif _is_within(path, components_dir):
hmr_update("site", "rebuilding...")
if await asyncio.to_thread(
generator.generate, verbose=False, quiet=True
):
should_notify = True
elif "styles" in str(path) or "public" in str(path):
elif _is_within(path, styles_dir) or _is_within(path, public_dir):
hmr_update("assets", "rebuilding...")
await asyncio.to_thread(generator._copy_assets, verbose=False)
should_notify = True
elif path.name == "nitro.config.py":
hmr_update("config", "rebuilding...")
generator = Generator()
if await asyncio.to_thread(
generator.generate, verbose=False, quiet=True
):
should_notify = True
else:
hmr_update("site", "rebuilding...")
if await asyncio.to_thread(
Expand Down
23 changes: 7 additions & 16 deletions src/nitro/core/bundler.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,14 +126,11 @@ def generate_sitemap(
rel_path = html_file.relative_to(self.build_dir)
rel_path_str = str(rel_path).replace("\\", "/")

# Get metadata for this page
meta = page_metadata.get(rel_path_str, {})

# Check if page should be excluded from sitemap
if meta.get("sitemap") is False:
continue

# Skip draft pages
if meta.get("draft"):
continue

Expand All @@ -147,7 +144,6 @@ def generate_sitemap(

full_url = f"{base_url.rstrip('/')}/{url_path}"

# Determine lastmod from metadata or file mtime
if meta.get("lastmod"):
lastmod = meta["lastmod"]
elif meta.get("published"):
Expand All @@ -156,13 +152,11 @@ def generate_sitemap(
mtime = datetime.fromtimestamp(html_file.stat().st_mtime)
lastmod = mtime.strftime("%Y-%m-%d")

# Determine priority from metadata or defaults
if meta.get("sitemap_priority"):
priority = str(meta["sitemap_priority"])
else:
priority = "1.0" if url_path == "" else "0.8"

# Determine changefreq from metadata or default
changefreq = meta.get("sitemap_changefreq", "weekly")

urls.append(
Expand Down Expand Up @@ -209,6 +203,8 @@ def generate_robots_txt(self, base_url: str, output_path: Path) -> None:
except (IOError, OSError) as e:
error(f"Failed to write robots.txt: {e}")

FINGERPRINT_LEN = 12

def create_asset_manifest(self, output_path: Path) -> None:
"""Create asset manifest with file hashes."""
manifest = {}
Expand All @@ -217,7 +213,7 @@ def create_asset_manifest(self, output_path: Path) -> None:
if file_path.is_file():
hasher = hashlib.sha256()
hasher.update(file_path.read_bytes())
file_hash = hasher.hexdigest()[:8]
file_hash = hasher.hexdigest()[: self.FINGERPRINT_LEN]
rel_path = str(file_path.relative_to(self.build_dir))

manifest[rel_path] = {
Expand All @@ -233,8 +229,7 @@ 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}$")
_FINGERPRINT_RE = re.compile(r"\.[0-9a-f]{12}$")

def fingerprint_assets(self) -> Dict[str, str]:
"""Add content hashes to CSS and JS filenames for cache busting."""
Expand All @@ -248,13 +243,12 @@ 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)
content_hash = hasher.hexdigest()[:8]
content_hash = hasher.hexdigest()[: self.FINGERPRINT_LEN]

stem = asset_path.stem
suffix = asset_path.suffix
Expand All @@ -276,13 +270,10 @@ def fingerprint_assets(self) -> Dict[str, str]:

for old_path, new_path in path_mapping.items():
old_filename = Path(old_path).name
new_filename = Path(new_path).name

# Use regex to match href/src with optional quotes and optional leading slash
# This handles quoted, unquoted, and minified HTML attributes
# Match href/src attributes with optional quotes and an
# optional leading slash so minified output is covered too.
for path_variant in [old_path, old_filename]:
# Pattern matches: href="path", href='path', href=/path, href=path
# Captures the attribute name (href or src) and replaces with quoted new path
pattern = (
r'((?:href|src)=)["\']?(/?'
+ re.escape(path_variant)
Expand Down
Loading
Loading