From 8e9a1999955f2e8586d34c7b4d88f83b78b1234d Mon Sep 17 00:00:00 2001 From: Sean N Date: Fri, 17 Apr 2026 20:29:02 +0200 Subject: [PATCH] Added missing docstrings and bumped the version --- pyproject.toml | 2 +- src/nitro/__init__.py | 2 +- src/nitro/core/config.py | 39 +++++++++- src/nitro/core/env.py | 74 +++++++++++++------ src/nitro/core/images.py | 145 +++++++++++++++++++++++++++++-------- src/nitro/core/islands.py | 109 +++++++++++++++++++++++++--- src/nitro/core/page.py | 34 +++++++-- src/nitro/core/renderer.py | 2 +- tests/test_bundler.py | 2 +- tests/test_islands.py | 14 ++-- 10 files changed, 342 insertions(+), 81 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e9275a3..0a08c1a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "nitro-cli" -version = "1.0.13" +version = "1.0.14" description = "Build static sites with Python code instead of template engines" authors = [ {name = "Sean Nieuwoudt", email = "sean@nitro.sh"} diff --git a/src/nitro/__init__.py b/src/nitro/__init__.py index 3de7027..a6f72cd 100644 --- a/src/nitro/__init__.py +++ b/src/nitro/__init__.py @@ -1,6 +1,6 @@ """Nitro CLI - A static site generator""" -__version__ = "1.0.10" +__version__ = "1.0.14" __author__ = "Sean Nieuwoudt" from .core.config import Config diff --git a/src/nitro/core/config.py b/src/nitro/core/config.py index 579f1fb..456c638 100644 --- a/src/nitro/core/config.py +++ b/src/nitro/core/config.py @@ -5,7 +5,32 @@ class Config: - """Configuration class for Nitro projects.""" + """Project-level configuration for a Nitro site. + + A `Config` instance is typically declared in `nitro.config.py` at the + project root as a module-level `config = Config(...)` binding. The CLI + loads it via `load_config()` during build and dev workflows, and its + fields drive output paths, URL rewriting, renderer behavior, and plugin + activation. + + Attributes: + site_name: Human-readable site name, available to plugins and templates. + base_url: Canonical site URL, used for sitemaps, absolute links, and RSS. + build_dir: Directory where generated HTML and assets are written. + source_dir: Directory containing `pages/`, `data/`, and other source files. + renderer: Renderer options (e.g. `minify_html`, `pretty_print`). Merges + with defaults; user keys win. + plugins: Dotted import paths of plugins to activate for this project. + clean_urls: If True, emit `about/index.html` so URLs can omit `.html`. + + Example: + >>> from nitro import Config + >>> config = Config( + ... site_name="My Site", + ... base_url="https://mysite.com", + ... renderer={"minify_html": True}, + ... ) + """ def __init__( self, @@ -17,6 +42,18 @@ def __init__( plugins: Optional[List[str]] = None, clean_urls: bool = True, ): + """Initialize a Config with project settings. + + Args: + site_name: Human-readable site name. + base_url: Canonical site URL (no trailing slash required). + build_dir: Output directory for generated files, relative to project root. + source_dir: Source directory containing pages and data. + renderer: Renderer overrides merged on top of + `{"pretty_print": False, "minify_html": False}`. + plugins: Dotted import paths of plugins to enable. + clean_urls: When True, write `about/index.html` instead of `about.html`. + """ self.site_name = site_name self.base_url = base_url self.build_dir = Path(build_dir) diff --git a/src/nitro/core/env.py b/src/nitro/core/env.py index eefc92e..46904cd 100644 --- a/src/nitro/core/env.py +++ b/src/nitro/core/env.py @@ -5,23 +5,28 @@ class Env: - """Lazy-loading environment variable accessor. + """Lazy-loading accessor for environment variables. - Automatically loads .env file on first access if python-dotenv is installed. + Reads variables from `os.environ` and, if `python-dotenv` is installed, + auto-loads a `.env` file from the current working directory on first + access. Missing variables return `""` rather than raising, so pages can + treat optional config as falsy without guarding every lookup. - Usage: - from nitro import env + A module-level instance is exported as `nitro.env` - prefer that over + constructing `Env()` directly. - # Access environment variables as attributes - api_key = env.API_KEY - - # Check if in production - if env.is_production(): - # Production-only code - pass + Example: + >>> from nitro import env + >>> api_key = env.API_KEY + >>> if env.is_production(): + ... ... """ def __init__(self): + """Initialize the accessor in an unloaded state. + + The `.env` file is not touched until the first variable read. + """ self._loaded = False def _load(self): @@ -43,13 +48,24 @@ def _load(self): self._loaded = True def __getattr__(self, name: str) -> str: - """Get environment variable by attribute name. + """Return the environment variable named `name`. + + Triggers a one-time `.env` load on first access. Args: - name: Environment variable name + name: Environment variable name. Leading underscores are reserved + for internal state and raise `AttributeError`. Returns: - Value of environment variable, or empty string if not set + The variable's value, or `""` if it is not set. + + Raises: + AttributeError: If `name` starts with an underscore. + + Example: + >>> from nitro import env + >>> env.DATABASE_URL + 'postgres://...' """ if name.startswith("_"): raise AttributeError(name) @@ -58,31 +74,45 @@ def __getattr__(self, name: str) -> str: return os.environ.get(name, "") def get(self, name: str, default: str = "") -> str: - """Get environment variable with optional default. + """Return an environment variable, falling back to `default`. + + Use this instead of attribute access when you need a non-empty + fallback, or when the variable name is only known at runtime. Args: - name: Environment variable name - default: Default value if not set + name: Environment variable name. + default: Value to return when the variable is unset. Returns: - Value of environment variable, or default if not set + The variable's value, or `default` if it is not set. + + Example: + >>> from nitro import env + >>> env.get("LOG_LEVEL", "info") + 'info' """ self._load() return os.environ.get(name, default) def is_production(self) -> bool: - """Check if running in production mode. + """Return True when running under a production build. + + Detected by `NITRO_ENV=production` in the environment; the CLI sets + this for you during `nitro build`. Returns: - True if NITRO_ENV is set to 'production' + True if `NITRO_ENV` equals `"production"`, else False. """ return os.environ.get("NITRO_ENV") == "production" def is_development(self) -> bool: - """Check if running in development mode. + """Return True when not running under a production build. + + Complement of `is_production()`; treats any missing or non-production + `NITRO_ENV` value as development. Returns: - True if not in production mode + True unless `NITRO_ENV` equals `"production"`. """ return not self.is_production() diff --git a/src/nitro/core/images.py b/src/nitro/core/images.py index 940dffb..be5a4e6 100644 --- a/src/nitro/core/images.py +++ b/src/nitro/core/images.py @@ -20,7 +20,17 @@ @dataclass class ImageConfig: - """Configuration for image optimization.""" + """Tunable settings for the image optimization pipeline. + + Controls responsive breakpoints, output formats, per-format quality, + lazy loading, and output layout. Pass an instance to `ImageOptimizer` + to override the defaults. + + Example: + >>> from nitro import ImageConfig, ImageOptimizer + >>> cfg = ImageConfig(sizes=[640, 1280], formats=["webp", "original"]) + >>> optimizer = ImageOptimizer(cfg) + """ # Responsive breakpoints (widths in pixels) sizes: List[int] = field(default_factory=lambda: [320, 640, 768, 1024, 1280, 1920]) @@ -56,7 +66,19 @@ class ImageConfig: @dataclass class OptimizedImage: - """Represents an optimized image with all variants.""" + """Result of optimizing a single source image. + + Holds the source metadata plus a mapping of generated variants keyed + by output format and width. `ImageOptimizer` produces these; consumers + typically call `get_srcset()` / `get_src()` to build HTML. + + Attributes: + original_path: Path to the source image on disk. + original_width: Intrinsic width of the source, in pixels. + original_height: Intrinsic height of the source, in pixels. + variants: `{format: {width: output_path}}` for every generated variant. + hash: Short content hash embedded in output filenames for cache busting. + """ original_path: Path original_width: int @@ -65,13 +87,20 @@ class OptimizedImage: hash: str def get_srcset(self, format: str = "webp") -> str: - """Generate srcset attribute for a format. + """Return a `srcset` attribute value for the given format. + + Emits one `path widthW` pair per generated variant, sorted by width. Args: - format: Image format (webp, avif, or original extension) + format: Output format key present in `variants` (e.g. `"webp"`, + `"avif"`, or the source extension like `"jpeg"`). Returns: - srcset string + A comma-separated srcset, or `""` if no variants exist for the format. + + Example: + >>> optimized.get_srcset("webp") + '_images/photo-640w-ab12cd.webp 640w, _images/photo-1280w-ab12cd.webp 1280w' """ if format not in self.variants: return "" @@ -83,14 +112,23 @@ def get_srcset(self, format: str = "webp") -> str: return ", ".join(parts) def get_src(self, format: str = "webp", width: Optional[int] = None) -> str: - """Get single src for a format/width. + """Return a single `src` path for the given format and width. + + Falls back to the largest generated width when `width` is omitted or + unavailable, and to the original source path if the format has no + variants at all. Args: - format: Image format - width: Specific width (or largest if None) + format: Output format key (e.g. `"webp"`, `"avif"`). + width: Specific width to select; if None or missing, the largest + generated width is returned. Returns: - Path string + A path string suitable for an `` attribute. + + Example: + >>> optimized.get_src("webp", 640) + '_images/photo-640w-ab12cd.webp' """ if format not in self.variants: return str(self.original_path) @@ -105,13 +143,31 @@ def get_src(self, format: str = "webp", width: Optional[int] = None) -> str: class ImageOptimizer: - """Handles image optimization for builds.""" + """Build-time image optimizer that produces responsive variants. + + Given a source image, generates multiple widths per configured format + (AVIF, WebP, and the original) and returns an `OptimizedImage` you can + splice into HTML via `generate_picture_element()` or `process_html()`. + + Requires Pillow; AVIF output additionally requires an AVIF-capable + Pillow build (install with the `[images]` extra). Failures degrade + gracefully - missing dependencies log a warning and return `None` + rather than raising. + + Example: + >>> from nitro import ImageOptimizer + >>> optimizer = ImageOptimizer() + >>> result = optimizer.optimize_image( + ... Path("src/photo.jpg"), Path("build"), base_url="/" + ... ) + >>> html = optimizer.generate_picture_element(result, alt="A photo") + """ def __init__(self, config: Optional[ImageConfig] = None): - """Initialize the image optimizer. + """Initialize the optimizer, optionally with custom settings. Args: - config: Image optimization configuration + config: Pipeline settings. Defaults to `ImageConfig()` when None. """ self.config = config or ImageConfig() self._cache: Dict[str, OptimizedImage] = {} @@ -161,15 +217,28 @@ def optimize_image( output_dir: Path, base_url: str = "", ) -> Optional[OptimizedImage]: - """Optimize a single image. + """Generate responsive variants for a single source image. + + Writes one file per `(format, width)` combination under + `output_dir//` with a content hash in the filename, + skipping widths larger than the source or larger than + `config.max_width`. Results are memoized per `(source, hash)`, so + repeat calls within a build are cheap. Args: - source_path: Path to source image - output_dir: Directory for optimized outputs - base_url: Base URL prefix for paths + source_path: Path to the source image. + output_dir: Build output directory; variants are written beneath + `output_dir / config.output_dir`. + base_url: URL prefix prepended to variant paths returned in + `OptimizedImage.variants`. Returns: - OptimizedImage with all variants, or None on failure + An `OptimizedImage` with every generated variant, or `None` when + Pillow is missing, the file is below `config.min_size`, no sizes + apply, or any I/O error occurs. + + Example: + >>> optimizer.optimize_image(Path("src/hero.jpg"), Path("build"), "/") """ if not self._check_pillow(): return None @@ -317,16 +386,24 @@ def generate_picture_element( css_class: str = "", sizes: Optional[str] = None, ) -> str: - """Generate HTML picture element with sources. + """Build a responsive `` element for an optimized image. + + Emits one `` per non-original format in preference order, plus + a fallback `` using the source's original format when available. + Intrinsic `width`/`height` attributes are set to help the browser + reserve layout space. Args: - optimized: OptimizedImage instance - alt: Alt text - css_class: CSS class for img tag - sizes: sizes attribute (or use default) + optimized: Result returned by `optimize_image()`. + alt: Text for the `alt` attribute. + css_class: Value for the fallback img's `class` attribute. + sizes: Explicit `sizes` attribute, else `config.default_sizes`. Returns: - HTML string + A multi-line HTML string containing ``. + + Example: + >>> optimizer.generate_picture_element(result, alt="Hero image") """ sizes_attr = sizes or self.config.default_sizes lazy_attr = 'loading="lazy"' if self.config.lazy_load else "" @@ -376,18 +453,26 @@ def process_html( output_dir: Path, base_url: str = "", ) -> str: - """Process HTML and optimize referenced images. + """Rewrite `` tags in HTML into optimized `` elements. - Finds img tags and replaces them with picture elements. + Scans the document for local `` tags with jpg/jpeg/png/gif + sources, runs `optimize_image()` on each, and swaps them for the + corresponding picture element. External URLs (`http(s)://`, `//`, + `data:`) and already-optimized `/_images/` paths are left alone, as + are sources that cannot be located on disk. Args: - html_content: HTML content - source_dir: Directory containing source images - output_dir: Build output directory - base_url: Base URL for image paths + html_content: Full HTML document or fragment to process. + source_dir: Directory that image paths are resolved against. + output_dir: Build output directory passed to `optimize_image()`. + base_url: URL prefix applied to generated variant paths. Returns: - Modified HTML with optimized images + The input HTML with local `` tags replaced by `` + blocks. Returns `html_content` unchanged when Pillow is missing. + + Example: + >>> optimizer.process_html(html, Path("src"), Path("build"), "/") """ if not self._check_pillow(): return html_content diff --git a/src/nitro/core/islands.py b/src/nitro/core/islands.py index 7de62e9..79a9aa1 100644 --- a/src/nitro/core/islands.py +++ b/src/nitro/core/islands.py @@ -25,7 +25,16 @@ @dataclass class IslandConfig: - """Configuration for islands.""" + """Tunable settings for the islands hydration pipeline. + + Controls where island scripts are emitted, the default hydration + strategy applied when an `Island` does not specify one, and debug + logging in the injected runtime. + + Example: + >>> from nitro import IslandConfig, IslandProcessor + >>> processor = IslandProcessor(IslandConfig(default_strategy="visible")) + """ # Output directory for island scripts (relative to build) output_dir: str = "_islands" @@ -39,10 +48,33 @@ class IslandConfig: @dataclass class Island: - """An interactive island component. - - Islands are components that need client-side JavaScript to function. - They are rendered as static HTML on the server and hydrated on the client. + """An interactive component that hydrates on the client. + + Islands bridge static HTML and client-side interactivity: the server + renders the component once into a wrapper `
` with `data-island-*` + attributes, and the runtime injected by `IslandProcessor` hydrates it + according to the configured strategy (`load`, `idle`, `visible`, + `media`, `interaction`, or `none`). + + Attributes: + name: Component name used by the client registry (`__registerIsland`). + component: A callable or object that renders the server-side HTML. + Callables are invoked with `**props`; nitro-ui elements returned + by the callable are rendered via `.render()`. + props: JSON-serializable props passed to the server render and + forwarded to the client via `data-props`. + client: Hydration strategy. See the module docstring for options. + client_only: Skip server rendering; emit a placeholder comment. + media: Media query used when `client="media"`. + + Example: + >>> from nitro import Island + >>> island = Island( + ... name="Counter", + ... component=lambda start: f"{start}", + ... props={"start": 0}, + ... client="visible", + ... ) """ name: str @@ -62,7 +94,22 @@ def __post_init__(self): self._id = f"{self.name}-{props_hash}" def render(self) -> str: - """Render the island with hydration markers.""" + """Render the island as HTML with hydration markers. + + Produces a `
` wrapping the server-rendered output (or a + placeholder comment when `client_only` is True) and annotated with + `data-island`, `data-island-id`, `data-hydrate`, and optionally + `data-props` / `data-media` so the client runtime can match and + hydrate it. Render failures are caught and surfaced as an HTML + comment rather than propagated. + + Returns: + A single `
` HTML fragment. + + Example: + >>> Island(name="Hello", component=lambda: "

hi

").render() + '

hi

' + """ # Render the component (server-side) if self.client_only: inner_html = "" @@ -108,13 +155,38 @@ def __str__(self) -> str: class IslandProcessor: - """Processes HTML to handle islands.""" + """Post-process HTML to wire up island hydration. + + Scans rendered HTML for `data-island` markers and, if any are present, + injects a small vanilla-JS runtime before `` that discovers + islands, loads props from `data-props`, and defers hydration per the + strategy in `data-hydrate`. + + Example: + >>> from nitro import IslandProcessor + >>> html = IslandProcessor().process_html("...") + """ def __init__(self, config: Optional[IslandConfig] = None): + """Initialize the processor, optionally with custom settings. + + Args: + config: Runtime/output settings. Defaults to `IslandConfig()` when None. + """ self.config = config or IslandConfig() def generate_hydration_script(self) -> str: - """Generate the client-side hydration script.""" + """Return the client-side hydration runtime as a JS source string. + + The script exposes `window.__registerIsland(name, component)` for + registering components, discovers islands on DOMContentLoaded, and + dispatches each to its strategy handler. Debug logging is included + when `config.debug` is True. + + Returns: + A string of JavaScript source, suitable for embedding in a + `