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
92 changes: 92 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,98 @@ Things that look reasonable and are wrong here:

---

## Extending Bengal

Four extension points. Pick the one that matches your need. If none fit, ask before changing the `Plugin` protocol or any of the 9 hook surfaces (`bengal/protocols/`) — see "Escape hatches."

### 1. Template function or filter

**When:** You need a new Jinja filter or global usable in `.html` templates (e.g. `{{ posts | my_sort }}`).

**Where:** Add a module under `bengal/rendering/template_functions/`. Each module exports a `register(env, site)` function and is wired into `register_all()` in that package's `__init__.py`. No decorator, no auto-discovery — append your `register()` call to the coordinator.

```python
# bengal/rendering/template_functions/my_funcs.py
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from bengal.protocols import SiteLike, TemplateEnvironment

def register(env: TemplateEnvironment, site: SiteLike) -> None:
env.filters["my_filter"] = lambda v: v.upper()
```

Then add `my_funcs.register(env, site)` to the appropriate phase in `register_all()`. Canonical example: `bengal/rendering/template_functions/strings.py`.

### 2. Content type strategy

**When:** A section needs custom sorting, pagination, filtering, or template defaults that built-in `content_type` strategies (`blog`, `doc`, `tutorial`, `changelog`, `archive`, `notebook`, `track`, `page`, `autodoc-python`, `autodoc-cli`) don't cover.

**Fastest path:** `bengal new content-type <name>` — scaffolds a `ContentTypeStrategy` subclass in the right place with `When to use:`, `default_template`, `allows_pagination`, `sort_pages()`, `detect_from_section()`, and the `register_strategy()` call already wired up. Then edit the TODOs.

**Manual path** (or to understand what the scaffold gives you): subclass `ContentTypeStrategy` from `bengal/content_types/base.py`, register the instance via `register_strategy()` from `bengal/content_types/registry.py`. Built-in strategies live in `bengal/content_types/strategies.py` — read `BlogStrategy` first, it's the canonical short example.

```python
from bengal.content_types import ContentTypeStrategy, register_strategy

class RecipeStrategy(ContentTypeStrategy):
default_template = "recipes/list.html"
allows_pagination = True
def sort_pages(self, pages):
return sorted(pages, key=lambda p: p.metadata.get("difficulty", 99))

register_strategy("recipe", RecipeStrategy())
```

Sections opt in by setting `type = "recipe"` in their `_index.md` frontmatter, or by `detect_from_section()` returning `True`.

### 3. CLI command

**When:** A new `bengal <verb>` operation — top-level command or subcommand under an existing group (`config`, `theme`, `content`, `version`, etc.).

**Where:** Write a function in `bengal/cli/milo_commands/`, then register it in `bengal/cli/milo_app.py` via `cli.lazy_command(...)`. There is **no filesystem auto-discovery** — the file has to be wired in `milo_app.py` to be reachable.

```python
# bengal/cli/milo_commands/hello.py
from __future__ import annotations
from typing import Annotated
from milo import Description

def hello(name: Annotated[str, Description("Who to greet")] = "world") -> dict:
"""Say hi."""
return {"status": "ok", "message": f"hello, {name}"}
```

Then in `bengal/cli/milo_app.py`:

```python
cli.lazy_command(
"hello",
import_path="bengal.cli.milo_commands.hello:hello",
description="Say hi",
)
```

**Milo ≠ Click — three things to know**:

- **Args:** `Annotated[type, Description("...")]` parameters with defaults — not `@click.option` decorators stacked above the function.
- **Discovery:** explicit `cli.lazy_command(name, import_path="module:fn")` in `milo_app.py`. Lazy import avoids the cold-start tax; the function isn't loaded until the command runs.
- **Output:** commands return `dict`. Milo handles structured output, MCP serialization, and `--json` for free. Don't `print()` — use `cli.render_write(template, ...)` for human output and the dict return for machine output.

Canonical example: `bengal/cli/milo_commands/clean.py` — small, real, exercises the full pattern (annotated args, dict return, `cli.error()` / `cli.tip()` for failure paths).

### 4. Build phase

**When:** You think you need to insert a custom step into the build pipeline.

**Where:** You don't. Build phases are hardcoded in `bengal/orchestration/build/__init__.py` — 21 numbered phases, called sequentially. Adding one is a design conversation, not a patch (see "Escape hatches").

**For observability** (timing, progress reporting): use `BuildOptions.on_phase_start` / `on_phase_complete` callbacks.

**For behavior changes mid-build**: use one of the 9 plugin hook surfaces in `bengal/protocols/` — those *are* the supported extension surface. Custom orchestration phases bypass the plugin contract and free-threading guarantees.

---

## Done criteria

A change is done when all of these hold:
Expand Down
7 changes: 7 additions & 0 deletions bengal/cli/milo_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,13 @@
display_result=False,
)

new.lazy_command(
"content-type",
import_path="bengal.cli.milo_commands.new:new_content_type",
description="Create a new ContentTypeStrategy scaffold",
display_result=False,
)

# ---------------------------------------------------------------------------
# Tier 2 — Feature groups (weekly use)
# ---------------------------------------------------------------------------
Expand Down
15 changes: 15 additions & 0 deletions bengal/cli/milo_commands/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ def build(
# sets quiet=True so the user sees the right error message.
if verbose and fast:
cli.error("--verbose and --fast cannot be used together (--fast implies --quiet)")
cli.tip("Pass only one — use --verbose for debugging, --fast for tight inner loops.")
raise SystemExit(2)

if dashboard:
Expand All @@ -175,23 +176,28 @@ def build(
conflicts.append("--full-output")
if conflicts:
cli.error(f"--dashboard cannot be used with {', '.join(conflicts)}")
cli.tip("--dashboard owns the terminal output; drop the conflicting flag(s).")
raise SystemExit(2)

if memory_optimized and perf_profile_path:
cli.error("--memory-optimized and --perf-profile cannot be used together")
cli.tip("Profile a normal run first, then re-run with --memory-optimized once tuned.")
raise SystemExit(2)

if verbose and quiet:
cli.error("--verbose and --quiet cannot be used together")
cli.tip("Pass only one — they're opposites.")
raise SystemExit(2)

if strict and continue_on_error:
cli.error("--strict and --continue-on-error cannot be used together")
cli.tip("Pass only one — they're opposites (fail-fast vs. tolerate).")
raise SystemExit(2)

error_format_val = (error_format or "text").lower()
if error_format_val not in {"text", "json"}:
cli.error(f"--error-format must be 'text' or 'json' (got: {error_format!r})")
cli.tip("Use --error-format text for humans, --error-format json for tooling.")
raise SystemExit(2)

# Apply fast mode after validation
Expand All @@ -211,18 +217,22 @@ def build(

if dev_profile and profile_val:
cli.error("--dev-profile is shorthand for --profile dev — use one or the other")
cli.tip("Drop --dev-profile and pass --profile dev (or vice versa).")
raise SystemExit(2)

if theme_dev and profile_val:
cli.error("--theme-dev is shorthand for --profile theme-dev — use one or the other")
cli.tip("Drop --theme-dev and pass --profile theme-dev (or vice versa).")
raise SystemExit(2)

if incremental and no_incremental:
cli.error("--incremental and --no-incremental cannot be used together")
cli.tip("Pass only one — they're opposites.")
raise SystemExit(2)

if assets_pipeline and no_assets_pipeline:
cli.error("--assets-pipeline and --no-assets-pipeline cannot be used together")
cli.tip("Pass only one — they're opposites.")
raise SystemExit(2)

# Determine build profile
Expand Down Expand Up @@ -344,6 +354,9 @@ def build(
if error_count > 0:
cli.blank()
cli.error(f"Validation failed with {error_count} error(s)")
cli.tip(
"Run `bengal check` for detailed diagnostics, or fix errors above and re-run."
)
raise SystemExit(1)
cli.blank()

Expand Down Expand Up @@ -524,11 +537,13 @@ def _build_versions(

if not getattr(site, "versioning_enabled", False):
cli.error("Versioning is not enabled (add versioning config to bengal.yaml)")
cli.tip("Add a [versions] section to bengal.yaml — see `bengal version --help`.")
raise SystemExit(1)

version_config = getattr(site, "version_config", None)
if not version_config or not version_config.is_git_mode:
cli.error("--version and --all-versions require git mode versioning")
cli.tip('Set `mode = "git"` in your [versions] config, or drop these flags.')
raise SystemExit(1)

from bengal.content.versioning import GitVersionAdapter
Expand Down
1 change: 1 addition & 0 deletions bengal/cli/milo_commands/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ def cache_hash(
hasher.update(file_path.read_bytes())
except OSError as e:
cli.error(f"Cannot read file '{file_path}': {e}")
cli.tip("Check file permissions or exclude unreadable paths from the hash.")
raise SystemExit(1) from e

result = hasher.hexdigest()[:16]
Expand Down
3 changes: 3 additions & 0 deletions bengal/cli/milo_commands/check.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ def check(

if report.has_errors():
cli.error(f"Validation failed: {errors} error(s) found")
cli.tip("Re-run with --suggestions for actionable fixes, or --verbose for full context.")
raise SystemExit(1)
if report.has_warnings():
cli.warning(f"Validation completed with {warnings} warning(s)")
Expand Down Expand Up @@ -219,6 +220,7 @@ def _run_validation(files_to_validate):

if report.has_errors():
cli.error(f"{report.total_errors} error(s) found")
cli.tip("Fix the issues above — watcher will re-run on save.")
elif report.has_warnings():
cli.warning(f"{report.total_warnings} warning(s)")
else:
Expand Down Expand Up @@ -310,4 +312,5 @@ def _validate_templates(site, pattern, show_hints, cli, templates, validate_cont
summary={"errors": len(errors), "warnings": 0, "passed": 0},
)
cli.error(f"Template validation failed: {len(errors)} error(s)")
cli.tip("See the validation report above — each issue includes a suggestion for how to fix it.")
raise SystemExit(1)
1 change: 1 addition & 0 deletions bengal/cli/milo_commands/codemod.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ def codemod(

if not target.exists() or not target.is_dir():
cli.error(f"Directory not found: {target}")
cli.tip("Pass a path to your site's templates/ directory, or the project root.")
raise SystemExit(1)

mode_badge = (
Expand Down
3 changes: 3 additions & 0 deletions bengal/cli/milo_commands/content.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,9 @@ def content_fetch(
return {"fetched": by_source, "total": sum(by_source.values())}
except Exception as e:
cli.error(f"Fetch failed: {e}")
cli.tip(
"Verify the source URL/path and any credentials in [content.sources] — re-run with --verbose for details."
)
raise SystemExit(1) from None


Expand Down
6 changes: 6 additions & 0 deletions bengal/cli/milo_commands/debug.py
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,9 @@ def debug_migrate(
cli.info(f" {prefix}{action}")
elif execute and not preview.can_proceed:
cli.error("Cannot proceed due to warnings")
cli.tip(
"Resolve the warnings shown above, or re-run without --execute to preview only."
)

return {
"move_from": move_from,
Expand All @@ -277,6 +280,9 @@ def debug_migrate(

if move_from or move_to:
cli.error("Both --move-from and --move-to are required for a move operation")
cli.tip(
"Pass both flags together, e.g. `bengal debug structure --move-from old/ --move-to new/`."
)
raise SystemExit(1)

report = migrator.run()
Expand Down
5 changes: 5 additions & 0 deletions bengal/cli/milo_commands/i18n.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ def i18n_compile(
cli.success(f"Compiled {loc_name}: {po_path.relative_to(root)} -> .mo")
except Exception as e:
cli.error(f"Failed to compile {po_path}: {e}")
cli.tip("Check the .po file for syntax errors — `msgfmt --check` can help locate them.")
raise

if compiled == 0:
Expand Down Expand Up @@ -146,6 +147,7 @@ def i18n_extract(
cli.success(f"Extracted {len(keys)} strings to {out.relative_to(root)}")
except Exception as e:
cli.error(f"Failed to write .pot: {e}")
cli.tip("Check write permissions on the i18n/ directory and that the output path exists.")
raise

return {"keys": len(keys), "output": str(out), "domain": domain}
Expand Down Expand Up @@ -294,9 +296,11 @@ def i18n_init(
cli.success(f"Created {po_path.relative_to(root)}")
except ImportError:
cli.error("polib is required: pip install bengal[gettext]")
cli.tip("Install the gettext extras with `pip install bengal[gettext]` then re-run.")
raise SystemExit(1) from None
except Exception as e:
cli.error(f"Failed to create {po_path}: {e}")
cli.tip("Check write permissions on the locale directory, then re-run.")
raise

if created:
Expand Down Expand Up @@ -351,6 +355,7 @@ def i18n_sync(
import polib
except ImportError:
cli.error("polib is required: pip install bengal[gettext]")
cli.tip("Install the gettext extras with `pip install bengal[gettext]` then re-run.")
raise SystemExit(1) from None

locales = [loc.strip() for loc in locale.split(",") if loc.strip()] if locale else []
Expand Down
2 changes: 2 additions & 0 deletions bengal/cli/milo_commands/inspect.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ def inspect_page(
warning_count = sum(1 for i in explanation.issues if i.severity == "warning")
if error_count:
cli.error(f"Found {error_count} error(s) and {warning_count} warning(s)")
cli.tip("See the issue list above — each entry points to the file, line, and fix.")
elif warning_count:
cli.warning(f"Found {warning_count} warning(s)")
else:
Expand Down Expand Up @@ -192,6 +193,7 @@ def inspect_links(
raise
except Exception as e:
cli.error(f"Link check failed: {e}")
cli.tip("Re-run with --traceback to see the full error, or narrow scope with --path.")
raise SystemExit(1) from e


Expand Down
Loading
Loading