diff --git a/dev_docs/guides/2026-02-24-module-system-guide.md b/dev_docs/guides/2026-02-24-module-system-guide.md index 8bf2f291..6b70184a 100644 --- a/dev_docs/guides/2026-02-24-module-system-guide.md +++ b/dev_docs/guides/2026-02-24-module-system-guide.md @@ -37,7 +37,7 @@ PhoenixKit's module system is a plugin architecture that lets feature modules se **Three components power the system:** -- **`PhoenixKit.Module`** — the behaviour contract (5 required + 8 optional callbacks) +- **`PhoenixKit.Module`** — the behaviour contract (5 required + 9 optional callbacks) - **`PhoenixKit.ModuleRegistry`** — GenServer + `:persistent_term` registry; zero-cost reads - **`PhoenixKit.ModuleDiscovery`** — zero-config beam file scanning; finds modules without config @@ -261,6 +261,26 @@ Module containing route macros injected into the router at compile time. **Defau Semantic version string. **Default:** `"0.0.0"`. Useful for external packages. +### `migration_module/0 :: module() | nil` + +Returns the versioned migration coordinator module for this plugin. **Default:** `nil` + +When set, `mix phoenix_kit.update` will auto-detect the module's migration state, compare `migrated_version_runtime()` with `current_version()`, and generate + run migration files if the database is behind. + +```elixir +def migration_module, do: MyModule.Migration +``` + +The coordinator module must implement: +- `current_version/0` — returns the latest migration version (integer) +- `migrated_version_runtime/1` — reads the current DB version; accepts `[prefix: "public"]` options (safe to call outside migration context) +- `up/1` — runs migrations; accepts `[prefix: "public", version: target]` options +- `down/1` — rolls back migrations; accepts `[prefix: "public", version: target]` options + +Version is tracked via a SQL comment on a designated table (e.g., `COMMENT ON TABLE phoenix_kit_my_items IS '2'`). Each version is an immutable module (e.g., `MyModule.Migration.Postgres.V01`) that the coordinator calls via `Module.concat/1`. + +See `PhoenixKitDocumentCreator.Migration` for a production example. + --- ## Folder Structure Convention @@ -435,6 +455,42 @@ end Routes are generated at compile time via `compile_plugin_admin_routes/0` in `integration.ex`. A recompile is required after adding a new external module. +### Navigation Paths in Templates + +Tab struct `path` fields use a relative convention (`"my-module"` → core prepends `/admin/`). But `href` attributes in HEEx templates and `redirect/2` calls are raw — the browser or Phoenix sends the path as-is. These must go through `PhoenixKit.Utils.Routes.path/1`, which handles the URL prefix and locale prefix. + +**Create a Paths module** for your module to centralize all path construction: + +```elixir +defmodule PhoenixKitAnalytics.Paths do + alias PhoenixKit.Utils.Routes + + @base "/admin/analytics" + + def index, do: Routes.path(@base) + def show(id), do: Routes.path("#{@base}/#{id}") + def settings, do: Routes.path("#{@base}/settings") +end +``` + +Then in templates and LiveViews: + +```elixir +alias PhoenixKitAnalytics.Paths + +# Template href: +View + +# Server-side redirect: +redirect(socket, to: Paths.index()) +``` + +This gives the same "single point of change" benefit that Tab structs have for sidebar registration — if the admin path changes, you update `@base` in one file instead of every template. + +**Why not use relative paths in templates?** + +Tab struct `path` fields go through `Tab.resolve_path/2` at registration time. Template `href` attributes and `redirect(to:)` calls do not — they're raw HTML/Phoenix. Relative paths like `"my-module/items"` would be resolved by the browser relative to the current URL, which breaks when locale segments (e.g., `/ja/`) are in the path. + --- ## Enable / Disable Patterns @@ -523,7 +579,51 @@ end config :phoenix_kit, :modules, [PhoenixKitAnalytics] ``` -**5. Routes require recompile** after adding the dependency (standard Phoenix constraint). +**5. Create a Paths module** to centralize all navigation paths (see [Navigation Paths in Templates](#navigation-paths-in-templates)): + +```elixir +defmodule PhoenixKitAnalytics.Paths do + alias PhoenixKit.Utils.Routes + @base "/admin/analytics" + + def index, do: Routes.path(@base) + def show(id), do: Routes.path("#{@base}/#{id}") +end +``` + +**6. Routes require recompile** after adding the dependency (standard Phoenix constraint). + +--- + +## JavaScript in External Modules + +External modules **cannot inject into the parent app's asset pipeline** (`app.js`, `esbuild`, `node_modules`). All JavaScript must be delivered as **inline ` + """ + end +end +``` + +**Rules:** +- Register hooks on `window.PhoenixKitHooks` — PhoenixKit spreads this object into the LiveSocket +- Pages using hooks must use **full page load** (`redirect/2`, not `navigate/2`) so the inline script executes +- For large vendor libraries, ship minified files in `priv/static/vendor/` and load via `