diff --git a/README.md b/README.md index 2d67d8f..c7d22ad 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ A static site generator that lets you build websites using Python and [nitro-ui] - **Live Reload** - Development server with automatic browser refresh - **Incremental Builds** - Only rebuild changed pages - **Dynamic Routes** - Generate pages from data with `[slug].py` pattern +- **Draft Pages** - Mark pages as drafts to exclude from production builds +- **Environment Variables** - Auto-load `.env` files with `from nitro import env` - **Image Optimization** - Responsive images with WebP/AVIF conversion - **Islands Architecture** - Partial hydration for interactive components - **Plugin System** - Extend the build lifecycle with nitro-dispatch hooks @@ -83,15 +85,19 @@ def render(slug, title): ## Commands -| Command | Description | -|--------------------|-----------------------------------| -| `nitro new ` | Create new project | -| `nitro dev` | Start dev server with live reload | -| `nitro build` | Build for production | -| `nitro preview` | Preview production build | -| `nitro clean` | Remove build artifacts | -| `nitro deploy` | Deploy to hosting platform | -| `nitro info` | Show project and environment info | +| Command | Description | +|--------------------|------------------------------------| +| `nitro new ` | Create new project | +| `nitro init` | Initialize Nitro in current dir | +| `nitro dev` | Start dev server with live reload | +| `nitro build` | Build for production | +| `nitro preview` | Preview production build | +| `nitro routes` | List all routes | +| `nitro check` | Validate site without building | +| `nitro export` | Export site as zip archive | +| `nitro clean` | Remove build artifacts | +| `nitro deploy` | Deploy to hosting platform | +| `nitro info` | Show project and environment info | Run `nitro --help` for options. diff --git a/SKILL.md b/SKILL.md index 4144290..9356e2c 100644 --- a/SKILL.md +++ b/SKILL.md @@ -25,6 +25,7 @@ All public classes are importable from the top-level `nitro` package: from nitro import ( Config, # Project configuration Page, # Page object returned by render() + env, # Environment variable accessor (auto-loads .env) ImageConfig, # Image optimization settings ImageOptimizer, # Image optimization engine OptimizedImage, # Result of optimizing an image @@ -39,7 +40,7 @@ from nitro import ( All commands support `--verbose` / `-v` for detailed output and `--debug` for full tracebacks. ### `nitro new ` -Create a new project. +Create a new project with full scaffolding. ```bash nitro new my-site @@ -47,6 +48,14 @@ nitro new my-site --no-git # Skip git initialization nitro new my-site --no-install # Skip dependency installation ``` +### `nitro init` +Initialize Nitro in an existing directory (minimal scaffolding). + +```bash +nitro init # Create config, directories, starter page +nitro init --force # Overwrite existing files +``` + ### `nitro dev` / `nitro serve` Start development server with live reload. @@ -119,6 +128,32 @@ nitro info nitro info --json # Output as JSON ``` +### `nitro routes` +List all routes the site will generate. + +```bash +nitro routes # Table output with URL, source, type, status +nitro routes --json # JSON output +``` + +### `nitro check` +Validate site without building (render check + internal link check). + +```bash +nitro check # Check all pages and links +nitro check --verbose # Show detailed output +nitro check --no-links # Skip link checking +``` + +### `nitro export` +Export built site as a zip archive. + +```bash +nitro export # Export build/ to -.zip +nitro export -o site.zip # Custom output path +nitro export --build-first # Build before exporting +``` + ## Project Structure ``` @@ -207,9 +242,27 @@ Page( content=html_element, # Required: nitro-ui element meta={"key": "value"}, # Optional: meta tags dict (arbitrary keys) template="layout", # Optional: template name + draft=False, # Optional: exclude from production builds ) ``` +### Draft Pages + +Pages with `draft=True` are: +- Rendered during development (`nitro dev`) +- Excluded from production builds (`nitro build`) +- Excluded from sitemap generation +- Shown with "draft" status in `nitro routes` + +```python +def render(): + return Page( + title="Work in Progress", + content=html_element, + draft=True, # Won't be included in production build + ) +``` + ### File Path to URL Mapping | File Path | Output URL | @@ -805,7 +858,7 @@ Production builds (`nitro build`) include: 4. **Responsive Images** - Generates multi-size AVIF/WebP variants (`--no-responsive` to skip) 5. **Island Processing** - Injects hydration runtime for islands (`--no-islands` to skip) 6. **Asset Fingerprinting** - Adds content hashes to CSS/JS filenames for cache busting -7. **Sitemap Generation** - Creates `sitemap.xml` with all pages +7. **Sitemap Generation** - Creates `sitemap.xml` with all pages (respects page meta) 8. **Robots.txt** - Creates `robots.txt` pointing to sitemap 9. **Asset Manifest** - Creates `manifest.json` with file hashes and sizes 10. **Incremental Builds** - Only rebuilds changed pages (use `--force` to bypass) @@ -917,13 +970,36 @@ Picture( # (standard tags are automatically replaced with during build) ``` +### Environment Variables + +Use `env` to access environment variables with automatic `.env` file loading: + +```python +from nitro import env + +# Access variables as attributes +api_key = env.API_KEY +debug_mode = env.DEBUG + +# Check environment +if env.is_production(): + # Production-only code + pass + +if env.is_development(): + # Dev-only code + pass +``` + +Requires `python-dotenv` for `.env` file support: `pip install nitro-cli[dotenv]` + ### Conditional Content ```python -def render(): - is_production = os.getenv("NODE_ENV") == "production" +from nitro import env - analytics = Script(src="/analytics.js") if is_production else None +def render(): + analytics = Script(src="/analytics.js") if env.is_production() else None return HTML( Head(Title("Page"), analytics), @@ -955,17 +1031,37 @@ nitro build --debug # Full tracebacks nitro dev --verbose # Detailed logging ``` +## Sitemap Customization + +Control sitemap generation via page `meta`: + +```python +Page( + title="My Page", + content=html, + meta={ + "sitemap": False, # Exclude from sitemap + "lastmod": "2024-01-15", # Custom last modified date + "sitemap_priority": 0.9, # Priority (0.0-1.0, default: 0.8) + "sitemap_changefreq": "daily", # Change frequency + }, +) +``` + +Draft pages are automatically excluded from the sitemap. + ## Dependencies -- **nitro-ui** >= 1.0.5 - HTML element builder +- **nitro-ui** >= 1.0.6 - HTML element builder - **nitro-datastore** >= 1.0.2 - Data loading with dot notation - **nitro-dispatch** >= 1.0.0 - Plugin system hooks -**Optional (for build optimizations):** +**Optional:** +- **python-dotenv** - `.env` file support (`pip install nitro-cli[dotenv]`) - **Pillow** - Image optimization and responsive image generation - **csscompressor** - CSS minification - **htmlmin** - HTML minification ## Version -Current: nitro-cli 1.0.7 +Current: nitro-cli 1.0.8 diff --git a/pyproject.toml b/pyproject.toml index 4cb3ec0..fab02e7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "nitro-cli" -version = "1.0.7" +version = "1.0.8" description = "Build static sites with Python code instead of template engines" authors = [ {name = "Sean Nieuwoudt", email = "sean@nitro.sh"} @@ -46,7 +46,7 @@ dependencies = [ "csscompressor>=0.9.5", "minify-html>=0.15.0", "pillow>=10.0.0", - "nitro-ui>=1.0.5", + "nitro-ui>=1.0.6", "nitro-datastore>=1.0.2", "nitro-dispatch>=1.0.0", "beautifulsoup4>=4.12.0", @@ -66,6 +66,9 @@ markdown = [ images = [ "pillow-avif-plugin>=1.3.0", ] +dotenv = [ + "python-dotenv>=1.0.0", +] [project.scripts] nitro = "nitro.cli:main" diff --git a/src/nitro/__init__.py b/src/nitro/__init__.py index 1fadd8d..009b279 100644 --- a/src/nitro/__init__.py +++ b/src/nitro/__init__.py @@ -5,6 +5,7 @@ from .core.config import Config from .core.page import Page +from .core.env import env from .core.images import ( ImageConfig, ImageOptimizer, @@ -19,6 +20,7 @@ __all__ = [ "Config", "Page", + "env", # Images "ImageConfig", "ImageOptimizer", diff --git a/src/nitro/cli.py b/src/nitro/cli.py index c8bbfa6..c240580 100644 --- a/src/nitro/cli.py +++ b/src/nitro/cli.py @@ -5,7 +5,20 @@ from rich.text import Text from rich.table import Table from . import __version__ -from .commands import new, serve, dev, build, preview, clean, info, deploy +from .commands import ( + new, + serve, + dev, + build, + preview, + clean, + info, + deploy, + init, + export_cmd, + routes, + check, +) from .core.page import get_project_root console = Console() @@ -50,11 +63,14 @@ def show_welcome(): table.add_column("description", style="dim") table.add_row(" nitro new [dim][/dim]", "Create a new project") + table.add_row(" nitro init", "Initialize Nitro in current directory") table.add_row(" nitro dev", "Start dev server with hot reload") table.add_row(" nitro build", "Build for production") table.add_row(" nitro preview", "Preview production build") + table.add_row(" nitro routes", "List all routes") + table.add_row(" nitro check", "Validate site without building") + table.add_row(" nitro export", "Export site as zip") table.add_row(" nitro clean", "Clean build artifacts") - table.add_row(" nitro info", "Show project info") console.print(" [bold]Commands:[/bold]\n") console.print(table) @@ -88,6 +104,10 @@ def main(ctx): main.add_command(clean) # type: ignore[arg-type] main.add_command(info) # type: ignore[arg-type] main.add_command(deploy) # type: ignore[arg-type] +main.add_command(init) # type: ignore[arg-type] +main.add_command(export_cmd, name="export") # type: ignore[arg-type] +main.add_command(routes) # type: ignore[arg-type] +main.add_command(check) # type: ignore[arg-type] if __name__ == "__main__": diff --git a/src/nitro/commands/__init__.py b/src/nitro/commands/__init__.py index 1f2f89d..ea3116b 100644 --- a/src/nitro/commands/__init__.py +++ b/src/nitro/commands/__init__.py @@ -8,6 +8,10 @@ from .clean import clean from .info import info from .deploy import deploy +from .init import init +from .export import export_cmd +from .routes import routes +from .check import check __all__ = [ "new", @@ -18,4 +22,8 @@ "clean", "info", "deploy", + "init", + "export_cmd", + "routes", + "check", ] diff --git a/src/nitro/commands/build.py b/src/nitro/commands/build.py index acb5856..fabfe8a 100644 --- a/src/nitro/commands/build.py +++ b/src/nitro/commands/build.py @@ -1,5 +1,6 @@ """Build command for production optimization.""" +import os import sys from datetime import datetime @@ -76,6 +77,9 @@ def build( set_level(LogLevel.QUIET) try: + # Set production environment variable + os.environ["NITRO_ENV"] = "production" + header("Building for production...") start_time = datetime.now() @@ -108,7 +112,7 @@ def build( update("Generating pages...") success_result = generator.generate( - verbose=verbose_flag, force=force or clean + verbose=verbose_flag, force=force or clean, production=True ) if not success_result: @@ -186,10 +190,13 @@ 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, ) verbose(f"Created sitemap.xml with {len(html_files)} URLs") diff --git a/src/nitro/commands/check.py b/src/nitro/commands/check.py new file mode 100644 index 0000000..516294e --- /dev/null +++ b/src/nitro/commands/check.py @@ -0,0 +1,209 @@ +"""Check command for validating site without building.""" + +import re +import sys +import click +from pathlib import Path +from typing import List, Tuple + +from ..core.page import get_project_root +from ..core.config import load_config +from ..core.renderer import Renderer +from ..utils import console, success, error, info, warning + + +def extract_internal_links(html: str) -> List[str]: + """Extract internal links from HTML content. + + Args: + html: HTML content string + + Returns: + List of internal link paths + """ + # Match href attributes that start with / (internal links) + pattern = r'href=["\'](/[^"\']*)["\']' + matches = re.findall(pattern, html) + + # Filter out anchors and external-looking paths + internal_links = [] + for link in matches: + # Skip anchor-only links + if link.startswith("/#"): + continue + # Remove query strings and anchors + path = link.split("?")[0].split("#")[0] + if path: + internal_links.append(path) + + return internal_links + + +@click.command() +@click.option("--verbose", "-v", is_flag=True, help="Show detailed output") +@click.option( + "--links/--no-links", default=True, help="Check internal links (default: enabled)" +) +def check(verbose, links): + """Validate the site without building.""" + project_root = get_project_root() + + if not project_root: + 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") + pages_dir = source_dir / "pages" + + if not pages_dir.exists(): + error("No pages directory found") + sys.exit(1) + + renderer = Renderer(config) + + # Track results + render_errors: List[Tuple[str, str]] = [] + link_errors: List[Tuple[str, str, str]] = [] + rendered_pages: dict = {} + valid_paths: set = set() + + info("Checking pages...") + + # Find all pages + page_files = sorted([f for f in pages_dir.rglob("*.py") if f.name != "__init__.py"]) + + if not page_files: + warning("No pages found") + sys.exit(0) + + # Pass 1: Render check - try to render each page + for py_file in page_files: + relative_path = py_file.relative_to(project_root) + + if verbose: + console.print(f" [dim]Checking {relative_path}[/]") + + # Check if dynamic route + is_dynamic = renderer.is_dynamic_route(py_file) + + if is_dynamic: + # Try to get dynamic paths and render each + try: + dynamic_paths = renderer.get_dynamic_paths(py_file, project_root) + for params in dynamic_paths: + try: + html = renderer.render_dynamic_page_single( + py_file, project_root, params + ) + if html: + # Build output path + param_name = py_file.stem[1:-1] + param_value = params.get(param_name, "") + parent = py_file.parent.relative_to(pages_dir) + + if str(parent) == ".": + url_path = f"/{param_value}" + else: + url_path = f"/{parent}/{param_value}" + + valid_paths.add(url_path) + valid_paths.add(url_path + "/") + rendered_pages[url_path] = html + except Exception as e: + render_errors.append((str(relative_path), str(e))) + except Exception as e: + render_errors.append((str(relative_path), f"Failed to get paths: {e}")) + else: + # Static page + try: + html = renderer.render_page(py_file, project_root) + if html: + # Build output path + stem = py_file.stem + parent = py_file.parent.relative_to(pages_dir) + + if stem == "index": + if str(parent) == ".": + url_path = "/" + else: + url_path = f"/{parent}/" + else: + if str(parent) == ".": + url_path = f"/{stem}" + else: + url_path = f"/{parent}/{stem}" + + valid_paths.add(url_path) + if not url_path.endswith("/"): + valid_paths.add(url_path + "/") + rendered_pages[url_path] = html + except Exception as e: + render_errors.append((str(relative_path), str(e))) + + # Add common static paths (these would be served from public/ or static/) + public_dir = source_dir / "public" + static_dir = project_root / "static" + + for static_source in [public_dir, static_dir]: + if static_source.exists(): + for f in static_source.rglob("*"): + if f.is_file(): + rel = f.relative_to(static_source) + valid_paths.add(f"/{rel}") + + # Pass 2: Link check - verify internal links exist + if links and rendered_pages: + info("Checking internal links...") + + for page_path, html in rendered_pages.items(): + internal_links = extract_internal_links(html) + + for link in internal_links: + # Normalize link path + normalized = link.rstrip("/") if link != "/" else link + normalized_with_slash = ( + normalized + "/" if not normalized.endswith("/") else normalized + ) + + # Check if target exists + if ( + normalized not in valid_paths + and normalized_with_slash not in valid_paths + and not link.startswith("/__") + ): # Skip internal nitro paths + link_errors.append((page_path, link, "Target not found")) + + # Report results + console.print() + + has_errors = bool(render_errors or link_errors) + + if render_errors: + console.print("[bold red]Render Errors:[/]") + for page, err in render_errors: + console.print(f" [red]✗[/] {page}") + if verbose: + console.print(f" [dim]{err}[/]") + console.print() + + if link_errors: + console.print("[bold yellow]Broken Links:[/]") + for page, link, reason in link_errors: + console.print(f" [yellow]⚠[/] {page} → {link}") + if verbose: + console.print(f" [dim]{reason}[/]") + console.print() + + # Summary + total_pages = len(rendered_pages) + + if has_errors: + error( + f"Found {len(render_errors)} render error(s) and {len(link_errors)} broken link(s)" + ) + sys.exit(1) + else: + success(f"All {total_pages} page(s) validated successfully") diff --git a/src/nitro/commands/export.py b/src/nitro/commands/export.py new file mode 100644 index 0000000..200a301 --- /dev/null +++ b/src/nitro/commands/export.py @@ -0,0 +1,102 @@ +"""Export command for creating deployable archives.""" + +import click +import zipfile +from datetime import datetime +from pathlib import Path + +from ..core.page import get_project_root +from ..core.config import load_config +from ..core.generator import Generator +from ..utils import success, error, info, warning + + +@click.command(name="export") +@click.option("--output", "-o", help="Output zip file path") +@click.option("--build-first", "-b", is_flag=True, help="Build before exporting") +def export_cmd(output, build_first): + """Export the built site as a zip file.""" + project_root = get_project_root() + + if not project_root: + error("Not in a Nitro project directory") + return + + # Load config for site name + config_path = project_root / "nitro.config.py" + config = load_config(config_path) if config_path.exists() else None + site_name = ( + getattr(config, "title", project_root.name) if config else project_root.name + ) + # Sanitize site name for filename + site_name = "".join(c if c.isalnum() or c in "-_" else "_" for c in site_name) + + build_dir = project_root / (config.build_dir if config else "build") + + # Build if requested or if build directory doesn't exist + if build_first or not build_dir.exists(): + if not build_dir.exists(): + info("Build directory not found, building first...") + else: + info("Building site...") + + generator = Generator(project_root) + if not generator.generate(): + error("Build failed, cannot export") + return + + # Check build directory exists and has files + if not build_dir.exists(): + error(f"Build directory not found: {build_dir}") + info("Run 'nitro build' first or use --build-first") + return + + build_files = list(build_dir.rglob("*")) + if not any(f.is_file() for f in build_files): + error("Build directory is empty") + return + + # Determine output path + if output: + output_path = Path(output) + if not output_path.suffix: + output_path = output_path.with_suffix(".zip") + else: + date_str = datetime.now().strftime("%Y%m%d") + output_path = project_root / f"{site_name}-{date_str}.zip" + + # Create zip file + file_count = 0 + total_size = 0 + + try: + with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zf: + for file_path in build_dir.rglob("*"): + if file_path.is_file(): + arcname = file_path.relative_to(build_dir) + zf.write(file_path, arcname) + file_count += 1 + total_size += file_path.stat().st_size + + zip_size = output_path.stat().st_size + + # Format sizes + if total_size < 1024 * 1024: + orig_size_str = f"{total_size / 1024:.1f}KB" + else: + orig_size_str = f"{total_size / (1024 * 1024):.1f}MB" + + if zip_size < 1024 * 1024: + zip_size_str = f"{zip_size / 1024:.1f}KB" + else: + zip_size_str = f"{zip_size / (1024 * 1024):.1f}MB" + + compression_ratio = (1 - zip_size / total_size) * 100 if total_size > 0 else 0 + + success(f"Exported {file_count} files to {output_path.name}") + info( + f" Original: {orig_size_str}, Compressed: {zip_size_str} ({compression_ratio:.0f}% smaller)" + ) + + except Exception as e: + error(f"Failed to create zip file: {e}") diff --git a/src/nitro/commands/init.py b/src/nitro/commands/init.py new file mode 100644 index 0000000..2af4d09 --- /dev/null +++ b/src/nitro/commands/init.py @@ -0,0 +1,147 @@ +"""Init command for initializing Nitro in current directory.""" + +import click +from pathlib import Path + +from ..utils import success, error, info, warning + + +DEFAULT_CONFIG = '''"""Nitro configuration file.""" + +# Site settings +title = "My Nitro Site" +base_url = "https://example.com" + +# Directory settings +source_dir = "src" +build_dir = "build" + +# Renderer settings +renderer = { + "minify_html": False, +} + +# Plugins (optional) +plugins = [] +''' + +DEFAULT_INDEX_PAGE = '''"""Home page.""" + +from nitro_ui import HTML, Head, Title, Meta, Body, Main, H1, Paragraph, Href +from nitro import Page + + +def render(): + content = HTML( + Head( + Title("Welcome to Nitro"), + Meta(charset="utf-8"), + Meta(name="viewport", content="width=device-width, initial-scale=1"), + ), + Body( + Main( + H1("Welcome to Nitro"), + Paragraph( + "Your site is ready. Edit ", + Href("src/pages/index.py", href="#"), + " to get started." + ), + ), + ), + ) + + return Page( + title="Welcome to Nitro", + content=content, + ) +''' + +DEFAULT_GITIGNORE = """# Build output +build/ +dist/ + +# Python +__pycache__/ +*.py[cod] +*$py.class +.Python +*.so +.eggs/ +*.egg-info/ +*.egg + +# Virtual environments +.venv/ +venv/ +ENV/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Nitro +.nitro_cache/ + +# Environment +.env +.env.local +""" + + +@click.command() +@click.option("--force", "-f", is_flag=True, help="Overwrite existing files") +def init(force): + """Initialize Nitro in the current directory.""" + cwd = Path.cwd() + + # Check if already a Nitro project + config_file = cwd / "nitro.config.py" + if config_file.exists() and not force: + warning("This directory already contains a nitro.config.py") + info("Use --force to overwrite existing files") + return + + # Create directory structure + directories = [ + "src/pages", + "src/components", + "src/styles", + "src/data", + "src/public", + ] + + created_dirs = 0 + for dir_name in directories: + dir_path = cwd / dir_name + if not dir_path.exists(): + dir_path.mkdir(parents=True, exist_ok=True) + created_dirs += 1 + + if created_dirs > 0: + success(f"Created {created_dirs} directories") + + # Create config file + if not config_file.exists() or force: + config_file.write_text(DEFAULT_CONFIG) + success("Created nitro.config.py") + + # Create .gitignore + gitignore_file = cwd / ".gitignore" + if not gitignore_file.exists() or force: + gitignore_file.write_text(DEFAULT_GITIGNORE) + success("Created .gitignore") + + # Create starter index page + index_file = cwd / "src" / "pages" / "index.py" + if not index_file.exists() or force: + index_file.write_text(DEFAULT_INDEX_PAGE) + success("Created src/pages/index.py") + + info("\nNitro project initialized!") + info("Run 'nitro dev' to start the development server") diff --git a/src/nitro/commands/routes.py b/src/nitro/commands/routes.py new file mode 100644 index 0000000..88a12b8 --- /dev/null +++ b/src/nitro/commands/routes.py @@ -0,0 +1,150 @@ +"""Routes command for listing all site routes.""" + +import json +import sys +import click +from pathlib import Path + +from ..core.page import get_project_root +from ..core.config import load_config +from ..core.renderer import Renderer +from ..utils import console, error + + +@click.command() +@click.option("--json", "as_json", is_flag=True, help="Output as JSON") +def routes(as_json): + """List all routes the site will generate.""" + project_root = get_project_root() + + if not project_root: + 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") + pages_dir = source_dir / "pages" + + if not pages_dir.exists(): + error("No pages directory found") + sys.exit(1) + + renderer = Renderer(config) + + # Find all pages + pages = [] + for py_file in sorted(pages_dir.rglob("*.py")): + if py_file.name == "__init__.py": + continue + + relative_path = py_file.relative_to(pages_dir) + source_file = str(py_file.relative_to(project_root)) + + # Check if dynamic route + is_dynamic = renderer.is_dynamic_route(py_file) + + if is_dynamic: + # Get all paths from dynamic route + try: + dynamic_paths = renderer.get_dynamic_paths(py_file, project_root) + for params in dynamic_paths: + # Build the output URL from params + param_name = py_file.stem[1:-1] # Extract name from [slug].py + param_value = params.get(param_name, "") + + # Build URL path + parent = relative_path.parent + if str(parent) == ".": + url = f"/{param_value}" + else: + url = f"/{parent}/{param_value}" + + # Check for draft status + draft = params.get("draft", False) + + pages.append( + { + "source": source_file, + "url": url, + "type": "dynamic", + "params": params, + "draft": draft, + } + ) + except Exception as e: + pages.append( + { + "source": source_file, + "url": f"/{relative_path.with_suffix('')}", + "type": "dynamic", + "error": str(e), + "draft": False, + } + ) + else: + # Static route + # Convert path to URL + stem = py_file.stem + parent = relative_path.parent + + if stem == "index": + if str(parent) == ".": + url = "/" + else: + url = f"/{parent}/" + else: + if str(parent) == ".": + url = f"/{stem}" + else: + url = f"/{parent}/{stem}" + + # Try to detect draft status by importing the page + draft = False + try: + page_result = renderer.render_page( + py_file, project_root, return_page=True + ) + if hasattr(page_result, "draft"): + draft = page_result.draft + except Exception: + pass + + pages.append( + { + "source": source_file, + "url": url, + "type": "static", + "draft": draft, + } + ) + + if as_json: + print(json.dumps(pages, indent=2)) + else: + # Rich table output + from rich.table import Table + + table = Table(show_header=True, header_style="bold") + table.add_column("URL", style="cyan") + table.add_column("Source", style="dim") + table.add_column("Type", style="green") + table.add_column("Status", style="yellow") + + for page in pages: + status = "" + if page.get("draft"): + status = "[yellow]draft[/]" + if page.get("error"): + status = f"[red]error: {page['error'][:30]}[/]" + + table.add_row( + page["url"], + page["source"], + page["type"], + status, + ) + + console.print(table) + console.print(f"\n[dim]Total: {len(pages)} route(s)[/]") diff --git a/src/nitro/commands/serve.py b/src/nitro/commands/serve.py index 4c96437..ac57d74 100644 --- a/src/nitro/commands/serve.py +++ b/src/nitro/commands/serve.py @@ -66,21 +66,19 @@ async def serve_async( generator = Generator() - if not generator.build_dir.exists() or not list( - generator.build_dir.rglob("*.html") - ): - with spinner("Generating site...") as update: - # Run blocking generation in thread pool to avoid blocking event loop - success_result = await asyncio.to_thread( - generator.generate, verbose=False, quiet=True + with spinner("Generating site...") as update: + # Run blocking generation in thread pool to avoid blocking event loop + # Force rebuild to ensure fresh state when starting dev server + success_result = await asyncio.to_thread( + generator.generate, verbose=False, quiet=True, force=True + ) + if not success_result: + error_panel( + "Generation Failed", + "Failed to generate site before starting server", + hint="Check your page files for syntax errors", ) - if not success_result: - error_panel( - "Generation Failed", - "Failed to generate site before starting server", - hint="Check your page files for syntax errors", - ) - return + return server = LiveReloadServer( build_dir=generator.build_dir, host=host, port=port, enable_reload=enable_reload diff --git a/src/nitro/core/bundler.py b/src/nitro/core/bundler.py index b7cbdd9..b319166 100644 --- a/src/nitro/core/bundler.py +++ b/src/nitro/core/bundler.py @@ -1,6 +1,6 @@ """Asset bundler and optimizer for production builds.""" -from typing import List, Dict +from typing import List, Dict, Optional from pathlib import Path import hashlib import re @@ -95,31 +95,77 @@ def optimize_images(self, quality: int = 85) -> int: return count def generate_sitemap( - self, base_url: str, html_files: List[Path], output_path: Path + self, + base_url: str, + html_files: List[Path], + output_path: Path, + page_metadata: Optional[Dict[str, Dict]] = None, ) -> None: - """Generate sitemap.xml.""" + """Generate sitemap.xml. + + Args: + base_url: Site base URL + html_files: List of HTML file paths + output_path: Path to write sitemap.xml + page_metadata: Optional dict of page metadata for enhanced sitemap + Keys are relative paths, values can include: + - sitemap: False to exclude from sitemap + - lastmod or published: Date string for lastmod + - sitemap_priority: Priority value (0.0-1.0) + - sitemap_changefreq: Change frequency + """ from datetime import datetime + page_metadata = page_metadata or {} + urls = [] for html_file in html_files: rel_path = html_file.relative_to(self.build_dir) - url_path = str(rel_path).replace("\\", "/") + 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 + url_path = rel_path_str if url_path == "index.html": url_path = "" elif url_path.endswith("/index.html"): url_path = url_path[:-11] full_url = f"{base_url.rstrip('/')}/{url_path}" - mtime = datetime.fromtimestamp(html_file.stat().st_mtime) - lastmod = mtime.strftime("%Y-%m-%d") + + # Determine lastmod from metadata or file mtime + if meta.get("lastmod"): + lastmod = meta["lastmod"] + elif meta.get("published"): + lastmod = meta["published"] + else: + 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( { "loc": full_url, "lastmod": lastmod, - "changefreq": "weekly", - "priority": "1.0" if url_path == "" else "0.8", + "changefreq": changefreq, + "priority": priority, } ) diff --git a/src/nitro/core/env.py b/src/nitro/core/env.py new file mode 100644 index 0000000..eefc92e --- /dev/null +++ b/src/nitro/core/env.py @@ -0,0 +1,91 @@ +"""Environment variable utilities for Nitro sites.""" + +import os +from pathlib import Path + + +class Env: + """Lazy-loading environment variable accessor. + + Automatically loads .env file on first access if python-dotenv is installed. + + Usage: + from nitro import env + + # Access environment variables as attributes + api_key = env.API_KEY + + # Check if in production + if env.is_production(): + # Production-only code + pass + """ + + def __init__(self): + self._loaded = False + + def _load(self): + """Load .env file if not already loaded.""" + if self._loaded: + return + + try: + from dotenv import load_dotenv + + # Try to find .env file, starting from cwd + env_file = Path.cwd() / ".env" + if env_file.exists(): + load_dotenv(env_file) + except ImportError: + # python-dotenv not installed, just use existing env vars + pass + + self._loaded = True + + def __getattr__(self, name: str) -> str: + """Get environment variable by attribute name. + + Args: + name: Environment variable name + + Returns: + Value of environment variable, or empty string if not set + """ + if name.startswith("_"): + raise AttributeError(name) + + self._load() + return os.environ.get(name, "") + + def get(self, name: str, default: str = "") -> str: + """Get environment variable with optional default. + + Args: + name: Environment variable name + default: Default value if not set + + Returns: + Value of environment variable, or default if not set + """ + self._load() + return os.environ.get(name, default) + + def is_production(self) -> bool: + """Check if running in production mode. + + Returns: + True if NITRO_ENV is set to 'production' + """ + return os.environ.get("NITRO_ENV") == "production" + + def is_development(self) -> bool: + """Check if running in development mode. + + Returns: + True if not in production mode + """ + return not self.is_production() + + +# Global env instance for convenient imports +env = Env() diff --git a/src/nitro/core/generator.py b/src/nitro/core/generator.py index 85b6c03..44a1b80 100644 --- a/src/nitro/core/generator.py +++ b/src/nitro/core/generator.py @@ -75,6 +75,7 @@ def generate( force: bool = False, parallel: bool = True, quiet: bool = False, + production: bool = False, ) -> bool: """Generate the static site. @@ -83,10 +84,13 @@ def generate( force: Force full rebuild, ignore cache parallel: Use parallel page generation (default: True) quiet: Suppress progress output (for background thread execution) + production: If True, exclude draft pages from output Returns: True if successful, False otherwise """ + self.production = production + self.page_metadata = {} # Store page metadata for sitemap info(f"Generating site from {self.source_dir}") info(f"Output directory: {self.build_dir}") @@ -373,7 +377,32 @@ def _render_page_sequential(self, page_path: Path, verbose: bool = False) -> boo Returns: True if successful, False otherwise """ - html = self.renderer.render_page(page_path, self.project_root) + # Render with page object return to check draft status + page_obj = self.renderer.render_page( + page_path, self.project_root, return_page=True + ) + + if page_obj is None: + return False + + # Check for draft status in production mode + is_draft = ( + getattr(page_obj, "draft", False) if hasattr(page_obj, "draft") else False + ) + if getattr(self, "production", False) and is_draft: + if verbose: + console.print( + f" [dim]Skipping draft: {page_path.relative_to(self.project_root)}[/]" + ) + return True # Return True to not count as failure + + # Get HTML content + if hasattr(page_obj, "content"): + html = self.renderer._render_page_object(page_obj) + html = self.renderer._post_process(html) + else: + html = self.renderer._render_element(page_obj) + html = self.renderer._post_process(html) if html: hook_result = self.plugin_loader.trigger( @@ -399,6 +428,17 @@ def _render_page_sequential(self, page_path: Path, verbose: bool = False) -> boo output_path.parent.mkdir(parents=True, exist_ok=True) output_path.write_text(html) + # Store metadata for sitemap + if hasattr(self, "page_metadata"): + rel_path = str(output_path.relative_to(self.build_dir)) + meta = ( + getattr(page_obj, "meta", {}) if hasattr(page_obj, "meta") else {} + ) + self.page_metadata[rel_path] = { + "draft": is_draft, + **meta, + } + if verbose: console.print(f" → {output_path.relative_to(self.project_root)}") @@ -436,7 +476,30 @@ def _render_single_page(self, page_path: Path, verbose: bool = False) -> bool: True if successful, False otherwise """ try: - html = self.renderer.render_page(page_path, self.project_root) + # Render with page object return to check draft status + page_obj = self.renderer.render_page( + page_path, self.project_root, return_page=True + ) + + if page_obj is None: + return False + + # Check for draft status in production mode + is_draft = ( + getattr(page_obj, "draft", False) + if hasattr(page_obj, "draft") + else False + ) + if getattr(self, "production", False) and is_draft: + return True # Skip drafts in production but count as success + + # Get HTML content + if hasattr(page_obj, "content"): + html = self.renderer._render_page_object(page_obj) + html = self.renderer._post_process(html) + else: + html = self.renderer._render_element(page_obj) + html = self.renderer._post_process(html) if html: # Trigger post-generate hook to allow HTML modification @@ -467,6 +530,19 @@ def _render_single_page(self, page_path: Path, verbose: bool = False) -> bool: # Write HTML file output_path.write_text(html) + # Store metadata for sitemap + if hasattr(self, "page_metadata"): + rel_path = str(output_path.relative_to(self.build_dir)) + meta = ( + getattr(page_obj, "meta", {}) + if hasattr(page_obj, "meta") + else {} + ) + self.page_metadata[rel_path] = { + "draft": is_draft, + **meta, + } + if verbose: console.print(f" → {output_path.relative_to(self.project_root)}") diff --git a/src/nitro/core/page.py b/src/nitro/core/page.py index 2e3ccea..cd93297 100644 --- a/src/nitro/core/page.py +++ b/src/nitro/core/page.py @@ -13,6 +13,7 @@ def __init__( content: Any, meta: Optional[Dict[str, Any]] = None, template: Optional[str] = None, + draft: bool = False, ): """ Initialize a page. @@ -22,11 +23,13 @@ def __init__( content: nitro-ui content (HTML element) meta: Meta tags dictionary template: Template name (if using a layout) + draft: If True, page is excluded from production builds """ self.title = title self.content = content self.meta = meta or {} self.template = template + self.draft = draft def get_project_root() -> Optional[Path]: diff --git a/src/nitro/core/renderer.py b/src/nitro/core/renderer.py index 29675a0..f419a58 100644 --- a/src/nitro/core/renderer.py +++ b/src/nitro/core/renderer.py @@ -25,6 +25,139 @@ def is_dynamic_route(self, page_path: Path) -> bool: """Check if a page uses dynamic routing (e.g., [slug].py).""" return "[" in page_path.stem and "]" in page_path.stem + def get_dynamic_paths(self, page_path: Path, project_root: Path) -> List[dict]: + """Get all paths for a dynamic route. + + Args: + page_path: Path to dynamic page file + project_root: Project root directory + + Returns: + List of parameter dictionaries from get_paths() + """ + paths_to_remove = [] + + try: + with _import_lock: + if str(project_root) not in sys.path: + sys.path.insert(0, str(project_root)) + paths_to_remove.append(str(project_root)) + + src_dir = project_root / "src" + if str(src_dir) not in sys.path: + sys.path.insert(0, str(src_dir)) + paths_to_remove.append(str(src_dir)) + + self._invalidate_project_modules(project_root) + + module_name = f"dynamic_paths_{page_path.stem}_{id(self)}" + spec = importlib.util.spec_from_file_location(module_name, page_path) + + if not spec or not spec.loader: + return [] + + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + + try: + spec.loader.exec_module(module) + + if not hasattr(module, "get_paths"): + return [] + + paths = module.get_paths() + # Normalize paths to list of dicts + result = [] + for path_params in paths: + if isinstance(path_params, dict): + result.append(path_params) + else: + # Single value - use the param name from filename + param_name = page_path.stem[1:-1] # Extract from [slug].py + result.append({param_name: path_params}) + return result + + finally: + if spec.name in sys.modules: + del sys.modules[spec.name] + + except Exception: + return [] + + finally: + with _import_lock: + for path in paths_to_remove: + if path in sys.path: + sys.path.remove(path) + + def render_dynamic_page_single( + self, page_path: Path, project_root: Path, params: dict + ) -> Optional[str]: + """Render a single instance of a dynamic page with given params. + + Args: + page_path: Path to dynamic page file + project_root: Project root directory + params: Parameters to pass to render() + + Returns: + Rendered HTML or None on error + """ + paths_to_remove = [] + + try: + with _import_lock: + if str(project_root) not in sys.path: + sys.path.insert(0, str(project_root)) + paths_to_remove.append(str(project_root)) + + src_dir = project_root / "src" + if str(src_dir) not in sys.path: + sys.path.insert(0, str(src_dir)) + paths_to_remove.append(str(src_dir)) + + self._invalidate_project_modules(project_root) + + module_name = f"dynamic_single_{page_path.stem}_{id(self)}" + spec = importlib.util.spec_from_file_location(module_name, page_path) + + if not spec or not spec.loader: + return None + + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + + try: + spec.loader.exec_module(module) + + if not hasattr(module, "render"): + return None + + page = module.render(**params) + + if isinstance(page, Page): + html = self._render_page_object(page) + else: + html = self._render_element(page) + + if html: + html = self._post_process(html) + + return html + + finally: + if spec.name in sys.modules: + del sys.modules[spec.name] + + except Exception: + return None + + finally: + with _import_lock: + for path in paths_to_remove: + if path in sys.path: + sys.path.remove(path) + def render_dynamic_page( self, page_path: Path, @@ -125,8 +258,19 @@ def _get_dynamic_output_name(self, page_path: Path, params: Any) -> str: return f"{output_name}.html" - def render_page(self, page_path: Path, project_root: Path) -> Optional[str]: - """Render a page file to HTML.""" + def render_page( + self, page_path: Path, project_root: Path, return_page: bool = False + ) -> Optional[Any]: + """Render a page file to HTML. + + Args: + page_path: Path to page file + project_root: Project root directory + return_page: If True, return the Page object instead of rendered HTML + + Returns: + HTML string, Page object (if return_page=True), or None on error + """ paths_to_remove = [] try: @@ -161,6 +305,10 @@ def render_page(self, page_path: Path, project_root: Path) -> Optional[str]: page = module.render() + # Return page object if requested + if return_page: + return page + if isinstance(page, Page): html = self._render_page_object(page) else: @@ -360,12 +508,22 @@ def _post_process(self, html: str) -> str: soup = BeautifulSoup(html, "html.parser") html = soup.prettify() except ImportError: - warning("beautifulsoup4 not installed, skipping pretty print (pip install beautifulsoup4)") + warning( + "beautifulsoup4 not installed, skipping pretty print (pip install beautifulsoup4)" + ) return html # Directories to exclude from module invalidation (virtual envs, installed packages) - _EXCLUDE_DIRS = {".venv", "venv", "site-packages", "dist-packages", ".tox", ".nox", ".eggs"} + _EXCLUDE_DIRS = { + ".venv", + "venv", + "site-packages", + "dist-packages", + ".tox", + ".nox", + ".eggs", + } def _invalidate_project_modules(self, project_root: Path) -> None: """Remove cached modules from project directory to ensure fresh imports.""" diff --git a/src/nitro/core/server.py b/src/nitro/core/server.py index a8ca843..51d08f8 100644 --- a/src/nitro/core/server.py +++ b/src/nitro/core/server.py @@ -38,13 +38,9 @@ async def _access_log_middleware(self, request, handler): if not request.path.startswith("/__nitro__"): status = resp.status if status >= 400: - console.print( - f" [red]{request.method} {request.path} {status}[/red]" - ) + console.print(f" [red]{request.method} {request.path} {status}[/red]") else: - console.print( - f" [dim]{request.method} {request.path} {status}[/dim]" - ) + console.print(f" [dim]{request.method} {request.path} {status}[/dim]") return resp def _setup_routes(self) -> None: diff --git a/src/nitro/templates/default/src/components/footer.py b/src/nitro/templates/default/src/components/footer.py index 0951cac..fdbc90d 100644 --- a/src/nitro/templates/default/src/components/footer.py +++ b/src/nitro/templates/default/src/components/footer.py @@ -14,7 +14,9 @@ def SiteFooter(): "Built with ", Href("Nitro", href="https://github.com/nitrosh/nitro-cli", target="_blank"), " and ", - Href("nitro-ui", href="https://github.com/nitrosh/nitro-ui", target="_blank"), + Href( + "nitro-ui", href="https://github.com/nitrosh/nitro-ui", target="_blank" + ), ), cls="footer", )