# Bx Sites Plugins

> Write or install a bx-sites (ortus-boxlang/bx-sites) plugin - the models/BxSitesPlugin.bx build-lifecycle hooks (onConfig, onPageMarkdown, onPageHtml, onNav, onSearchIndex, onSitemap, onBuildComplete), installing a published plugin from ForgeBox, and registering new bxSites CLI verbs via models/BxSitesCliProvider.bx. Use this whenever a user wants to extend bx-sites' build pipeline with custom logic, or add a new bxSites <verb> command.

- Skill: `ortus-boxlang/bx-sites-plugins` (Agent Skill)
- Install (CLI): `npx skillmds@latest add ortus-boxlang/bx-sites-plugins`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ortus-boxlang/bx-sites-plugins/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- Author: ortus-boxlang (https://skillmd.com/u/ortus-boxlang)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/ortus-boxlang/bx-sites-plugins

---


# BxSites Plugins Reference

A BxSites plugin is nothing more than another BoxLang module - its own
`box.json` + `ModuleConfig.bx`, installed as a sibling of `bx-sites` in the
same runtime. No plugin API to import, no separate registry - BoxLang's own
module system *is* the plugin system. Installing a module never activates
it as a plugin on its own:

```yaml title="bxsites.yaml"
plugins: [ myBxSitesPlugin ]
```

(see `bx-sites-configuration` for the `plugins` key)

## Installing a published plugin

```bash
bxSites install:plugin --name=bx-sites-plugin-analytics [--version=1.2.0]
```

Downloads from ForgeBox and extracts into
`boxlang_modules/bx-sites-plugin-analytics/` at the project root (BoxLang's
auto-loaded local-module convention - no `box`/CommandBox needed, no
`BOXLANG_HOME`/global install step). Loads it into the runtime immediately
and prints the real registered module mapping name (not always the same as
the ForgeBox slug) - add *that* name to `plugins`. Browse published plugins
under ForgeBox's `bxsites-plugins` category.

## Writing a plugin

One class beyond the usual module scaffolding: `models/BxSitesPlugin.bx`.
Every method is optional - bx-sites checks for each before calling it.

```bash
bxSites plugin:new --name=my-analytics-plugin [--dest=]
```

scaffolds `box.json`, `ModuleConfig.bx`, and a fully-stubbed
`models/BxSitesPlugin.bx` mirroring `examples/hello-plugin/` in the bx-sites
repo (a complete, working reference implementation - adds an HTML comment to
every page and appends a build summary via `onPageHtml`/`onBuildComplete`).

```bx title="models/BxSitesPlugin.bx"
class {

	struct function onConfig( required struct config ) {
		// Mutate/return the site config, right after bxsites.yaml is loaded.
		return arguments.config
	}

	string function onPageMarkdown( required string markdown, required struct page, required struct config ) {
		// Mutate a page's raw markdown before conversion.
		return arguments.markdown
	}

	string function onPageHtml( required string html, required struct page, required struct config ) {
		// Mutate a page's rendered HTML after conversion.
		return arguments.html
	}

	array function onNav( required array nav, required struct config ) {
		// Mutate the nav tree: array of { title, url, order, children } nodes.
		return arguments.nav
	}

	array function onSearchIndex( required array entries, required struct config ) {
		// Mutate search-index.json entries before it's written - each is
		// { title, url, headings, body, tags }. Runs once per index actually
		// written (main + each version/locale tree using a local index).
		return arguments.entries
	}

	array function onSitemap( required array pages, required struct config ) {
		// Mutate the accumulated page list right before BOTH sitemap.xml and
		// llms.txt are built from it. Only urlPath/title/hidden need be set.
		return arguments.pages
	}

	void function onBuildComplete( required string siteDir, required struct config ) {
		// Fires once, after everything is written to siteDir. No return value.
	}

}
```

Hooks run in `plugins` array order; each hook's return value (except
`onBuildComplete`) replaces the value the next hook (or bx-sites itself)
sees - return the input unchanged if there's nothing to modify.
`onPageMarkdown`/`onPageHtml` run once per page, per doc tree (main +
every `docs/versions/<name>/` - see `bx-sites-blog-versioning-i18n`).
`onSearchIndex`/`onSitemap` exist specifically for content living outside
`docs/` altogether (e.g. a dynamically-served page a CLI-provider addon
adds) that would otherwise be invisible to search/sitemap/`llms.txt`.

### Order of hook calls

```text
onConfig(config)
  -> build the nav tree
onNav(nav, config)
  -> for every page: onPageMarkdown(markdown, page, config) -> Markdown() -> onPageHtml(html, page, config)
  -> write site/
onSearchIndex(entries, config)
  -> write search-index.json
onSitemap(pages, config)
  -> write sitemap.xml + llms.txt
onBuildComplete(siteDir, config)
```

## Errors

- `BxSites.PluginNotFound` - a name in `plugins` isn't an installed/activated
  BoxLang module.
- `BxSites.InvalidPlugin` - the module exists but has no
  `models/BxSitesPlugin.bx`.

## Registering new `bxSites` CLI verbs (CLI providers)

`models/BxSitesPlugin.bx` only covers the build lifecycle. A module can also
register its own `bxSites <verb>` commands (e.g. a commercial `deploy`/
`cloud publish` addon) via a sibling `models/BxSitesCliProvider.bx` class
exposing one `verbs()` method - same `plugins` array activation, a different
file. A module can implement `BxSitesPlugin.bx`, `BxSitesCliProvider.bx`,
both, or neither.

```bx title="models/BxSitesCliProvider.bx"
class {
	struct function verbs() {
		return {
			"cloud:publish" : {
				class       : "models.cli.cloud.Publish@myBxSitesAddon",
				description : "Build (if needed) and publish via the configured deploy target"
			}
		}
	}
}
```

```bx title="models/cli/cloud/Publish.bx"
class {
	struct function run( struct options ) {
		// arguments.options carries the same parsed-flags-plus-projectRoot
		// shape every core verb receives.
		return { exitCode : 0, message : "Published #arguments.options.projectRoot#" }
	}
}
```

- **The `@myModuleMapping` suffix on the dispatch class path is required** -
  a bare dotted path like `"models.cli.cloud.Publish"` only resolves
  relative to `bx-sites`' own module root; a provider's own verb classes
  must self-supply their module's `@<mapping>` suffix to be reachable at all.
- **Verb names** are one colon-joined string (`"cloud:publish"`, the same
  convention core uses for `post:new`/`i18n:status`); `bxSites cloud publish`
  (two argv tokens) and `bxSites cloud:publish` dispatch identically once
  that name is registered - no separate two-word registration needed.
- **Core always wins** on a name collision with a built-in verb (the
  provider's entry is silently ignored - a provider can add commands, never
  shadow one). Between two providers, **first listed in `plugins` wins**.
  A missing/malformed config, no `BxSitesCliProvider.bx`, or a `verbs()`
  that throws never breaks core dispatch - it's treated like an unactivated
  plugin.

