diff --git a/.dialyzer_ignore.exs b/.dialyzer_ignore.exs index 7924c1814..e42bafa06 100644 --- a/.dialyzer_ignore.exs +++ b/.dialyzer_ignore.exs @@ -5,8 +5,6 @@ {"lib/mix/tasks/phoenix_kit.update.ex", :unknown_function}, {"lib/mix/tasks/phoenix_kit.gen.admin_page.ex", :unknown_function}, {"lib/mix/tasks/phoenix_kit.gen.dashboard_tab.ex", :unknown_function}, - {"lib/mix/tasks/phoenix_kit.migrate_blog_versions.ex", :unknown_function}, - {"lib/mix/tasks/phoenix_kit.migrate_blogging_to_publishing.ex", :unknown_function}, # Conditional compilation pattern match in update.ex (Code.ensure_loaded?) {"lib/mix/tasks/phoenix_kit.update.ex", :pattern_match, 1}, {"lib/mix/tasks/phoenix_kit.modernize_layouts.ex", :unknown_function}, @@ -61,13 +59,16 @@ {"lib/mix/tasks/phoenix_kit.sync_email_status.ex", :callback_info_missing, 1}, {"lib/mix/tasks/phoenix_kit.fix_missing_events.ex", :callback_info_missing, 1}, {"lib/mix/tasks/phoenix_kit.process_sqs.ex", :callback_info_missing, 1}, - {"lib/mix/tasks/phoenix_kit.migrate_blog_versions.ex", :callback_info_missing, 1}, - {"lib/mix/tasks/phoenix_kit.migrate_blogging_to_publishing.ex", :callback_info_missing, 1}, {"lib/mix/tasks/phoenix_kit.cleanup_orphaned_files.ex", :callback_info_missing, 1}, # False positive pattern match warnings (runtime behavior differs from static analysis) {"lib/mix/tasks/phoenix_kit/email_cleanup.ex", :pattern_match, 1}, - {"lib/mix/tasks/phoenix_kit.migrate_blogging_to_publishing.ex", :pattern_match_cov}, + + # Publishing module defensive fallbacks and settings_call dynamic dispatch + {"lib/modules/publishing/publishing.ex", :guard_fail}, + {"lib/modules/publishing/publishing.ex", :pattern_match_cov}, + {"lib/modules/publishing/publishing.ex", :pattern_match}, + {"lib/modules/publishing/shared.ex", :guard_fail}, # ExAws library type definition issues (false positives from incomplete type specs) ~r/lib\/modules\/emails\/archiver\.ex:.*pattern_match/, ~r/lib\/modules\/emails\/archiver\.ex:.*unused_fun/, @@ -75,8 +76,8 @@ # Ecto.Multi opaque type false positives (code works correctly) ~r/lib\/phoenix_kit\/users\/auth\.ex:.*call_without_opaque/, - # Legal module - dynamic dispatch to Blogging module - # Dialyzer can't infer types through blogging_module() helper + # Legal module - dynamic dispatch to Publishing module + # Dialyzer can't infer types through publishing_module() helper ~r/lib\/modules\/legal\/legal\.ex:.*pattern_match/, # ConsentLog schema - changeset type spec with empty struct 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 6b70184a7..598e10dc0 100644 --- a/dev_docs/guides/2026-02-24-module-system-guide.md +++ b/dev_docs/guides/2026-02-24-module-system-guide.md @@ -9,11 +9,16 @@ - [Optional Callbacks](#optional-callbacks) - [Folder Structure Convention](#folder-structure-convention) - [Admin Tabs](#admin-tabs) +- [Subtabs and Hidden Pages](#subtabs-and-hidden-pages) - [Settings Tabs](#settings-tabs) - [Permission Metadata](#permission-metadata) - [Supervisor Children](#supervisor-children) - [Route Integration](#route-integration) +- [Navigation System (Paths Module)](#navigation-system-paths-module) +- [Component Reuse](#component-reuse) +- [JavaScript in External Modules](#javascript-in-external-modules) - [Enable / Disable Patterns](#enable--disable-patterns) +- [Database and Migrations](#database-and-migrations) - [External Hex Packages](#external-hex-packages) - [Pitfalls for Developers and Agents](#pitfalls-for-developers-and-agents) - [Reference Files](#reference-files) @@ -165,9 +170,15 @@ def module_name, do: "Analytics" Whether the module is currently active. Called frequently — keep it cheap. The settings cache handles the DB read. ```elixir -def enabled?, do: Settings.get_boolean_setting("analytics_enabled", false) +def enabled? do + Settings.get_boolean_setting("analytics_enabled", false) +rescue + _ -> false +end ``` +The `rescue` clause is required — `enabled?/0` is called before migrations run, so the settings table may not exist yet. + ### `enable_system/0` and `disable_system/0` Enable or disable the module system-wide. Must return `:ok | {:ok, term()} | {:error, term()}`. @@ -285,6 +296,8 @@ See `PhoenixKitDocumentCreator.Migration` for a production example. ## Folder Structure Convention +### Internal modules (inside PhoenixKit) + All modules live in `lib/modules/` with the `PhoenixKit.Modules.` namespace. ``` @@ -302,31 +315,88 @@ lib/modules/analytics/ - The main context file (`analytics.ex`) is the one that `use PhoenixKit.Module` - Do not use `lib/phoenix_kit/modules/`, `lib/phoenix_kit_web/live/modules/`, or `lib/phoenix_kit/.ex` +### External modules (standalone packages) + +``` +lib/ + my_phoenix_kit_module.ex # Main module (behaviour callbacks) + my_phoenix_kit_module/ + paths.ex # Centralized path helpers + documents.ex # Context / business logic + schemas/ + item.ex # Ecto schemas + migration.ex # Migration coordinator + migration/postgres/ + v01.ex # Initial tables + v02.ex # Schema changes + web/ + index_live.ex # Main admin page + detail_live.ex # Detail/edit page + components/ + my_scripts.ex # JS hook component + item_card.ex # Shared UI component + editor_panel.ex # Shared editor component +mix/ + tasks/ + my_phoenix_kit_module.install.ex # Install task +``` + --- ## Admin Tabs -Admin sidebar tabs are defined in `admin_tabs/0` as `Tab.new!/1` structs. +Admin sidebar tabs are defined in `admin_tabs/0` as `%Tab{}` structs. ```elixir def admin_tabs do [ - Tab.new!( + %Tab{ id: :admin_analytics, # Atom — must be unique across ALL modules label: "Analytics", icon: "hero-chart-bar", - path: "/admin/analytics", # Must start with "/" - priority: 600, # Higher = appears higher in group + path: "analytics", # Relative slug — core prepends /admin/ + priority: 600, # Lower = higher in sidebar level: :admin, # Always :admin for admin sidebar permission: "analytics", # Must match module_key and permission_metadata.key match: :prefix, # :prefix or :exact - group: :admin_modules # :admin_main or :admin_modules - ) + group: :admin_modules, # :admin_main or :admin_modules + live_view: {MyModule.Web.IndexLive, :index} + } ] end ``` -**Priority reference** (existing modules, for insertion guidance): +### Tab struct complete reference + +| Field | Type | Default | Description | +|---|---|---|---| +| `:id` | atom | *required* | Unique identifier (prefix with `:admin_yourmodule`) | +| `:label` | string | *required* | Display text in sidebar | +| `:icon` | string | `nil` | Heroicon name (e.g., `"hero-chart-bar"`) | +| `:path` | string | *required* | Relative slug (`"my-module"`) or absolute (`"/admin/my-module"`) | +| `:priority` | integer | `500` | Sort order (lower = higher in sidebar) | +| `:level` | atom | `:user` | `:admin`, `:settings`, `:user`, or `:all` | +| `:permission` | string | `nil` | Permission key (use `module_key()`) | +| `:group` | atom | `nil` | Sidebar group (`:admin_modules` for module tabs) | +| `:match` | atom/fn | `:prefix` | `:exact`, `:prefix`, `{:regex, ~r/...}`, or `fn path -> bool end` | +| `:live_view` | tuple | `nil` | `{Module, :action}` for auto-routing | +| `:parent` | atom | `nil` | Parent tab ID (for subtabs or hidden sub-pages) | +| `:visible` | bool/fn | `true` | Show in sidebar. `false` hides it. Can be `fn scope -> bool end` | +| `:badge` | `Badge` | `nil` | Badge indicator (count, dot, status) | +| `:tooltip` | string | `nil` | Hover text | +| `:external` | bool | `false` | Whether this links to an external site | +| `:new_tab` | bool | `false` | Whether to open in a new browser tab | +| `:attention` | atom | `nil` | Animation: `:pulse`, `:bounce`, `:shake`, `:glow` | +| `:metadata` | map | `%{}` | Custom metadata for advanced use cases | +| `:subtab_display` | atom | `:when_active` | When to show subtabs: `:when_active` or `:always` | +| `:subtab_indent` | string | `nil` | Tailwind padding class (e.g., `"pl-6"`) | +| `:subtab_icon_size` | string | `nil` | Icon size class (e.g., `"w-3 h-3"`) | +| `:subtab_text_size` | string | `nil` | Text size class (e.g., `"text-xs"`) | +| `:subtab_animation` | atom | `nil` | `:none`, `:slide`, `:fade`, `:collapse` | +| `:redirect_to_first_subtab` | bool | `false` | Navigate to first subtab when clicking parent | +| `:highlight_with_subtabs` | bool | `false` | Keep parent highlighted when subtab is active | + +### Priority reference | Priority | Module | |----------|--------| @@ -341,15 +411,180 @@ end Use a value in an existing gap or adjust nearby modules if needed. -**Groups:** +### Groups + - `:admin_main` — core platform tabs (Dashboard, Users, Media, Settings) - `:admin_modules` — feature module tabs (everything else) -**Match modes:** +### Match modes + - `:prefix` — tab highlighted for the path and all sub-paths (use for modules with sub-pages) - `:exact` — tab highlighted only for the exact path -**Paths use hyphens, not underscores:** `/admin/magic-link`, not `/admin/magic_link`. +### Paths use hyphens, not underscores + +`/admin/magic-link`, not `/admin/magic_link`. + +--- + +## Subtabs and Hidden Pages + +For modules with multiple pages, use a combination of visible subtabs and hidden pages. + +### Visible subtabs + +Subtabs appear indented under their parent in the sidebar when the parent is active: + +```elixir +def admin_tabs do + [ + # Parent tab with subtab configuration + %Tab{ + id: :admin_my_module, + label: "My Module", + icon: "hero-puzzle-piece", + path: "my-module", + priority: 650, + level: :admin, + permission: module_key(), + match: :prefix, + group: :admin_modules, + subtab_display: :when_active, # Show subtabs only when parent is active + highlight_with_subtabs: false, # Don't highlight parent when subtab is active + live_view: {MyModule.Web.IndexLive, :index} + }, + # Visible subtab (appears in sidebar under parent) + %Tab{ + id: :admin_my_module_reports, + label: "Reports", + icon: "hero-chart-bar", + path: "my-module/reports", + priority: 651, + level: :admin, + permission: module_key(), + parent: :admin_my_module, + live_view: {MyModule.Web.ReportsLive, :index} + }, + # Another visible subtab + %Tab{ + id: :admin_my_module_settings, + label: "Settings", + icon: "hero-cog-6-tooth", + path: "my-module/settings", + priority: 652, + level: :admin, + permission: module_key(), + parent: :admin_my_module, + live_view: {MyModule.Web.SettingsLive, :index} + } + ] +end +``` + +**Subtab display modes:** +- `:when_active` — subtabs visible only when the parent tab or one of its subtabs is active +- `:always` — subtabs always visible regardless of parent state + +**`highlight_with_subtabs`:** +- `false` (default) — parent is not highlighted when a subtab is active +- `true` — parent stays highlighted when any subtab is active + +### Hidden pages + +For pages that need routes but shouldn't appear in the sidebar (edit pages, detail views, creation forms): + +```elixir +%Tab{ + id: :admin_my_module_item_edit, + path: "my-module/items/:uuid/edit", # Path parameters work + level: :admin, + permission: module_key(), + parent: :admin_my_module, # Keeps parent highlighted + visible: false, # Not shown in sidebar + live_view: {MyModule.Web.ItemEditorLive, :edit} +} +``` + +### Conditional tabs via config flags + +Gate tabs behind compile-time configuration: + +```elixir +@testing_mode Application.compile_env(:my_module, :testing_mode, false) + +@impl PhoenixKit.Module +def admin_tabs do + base_tabs() ++ testing_tabs() +end + +defp base_tabs do + [%Tab{id: :admin_my_module, ...}] +end + +defp testing_tabs do + if @testing_mode do + [%Tab{id: :admin_my_module_testing, label: "Testing", icon: "hero-beaker", ...}] + else + [] + end +end +``` + +Users enable via config: + +```elixir +config :my_module, :testing_mode, true +``` + +### Real-world example: Document Creator (14 tabs) + +```elixir +def admin_tabs do + [ + # Main landing page (visible, with subtab config) + %Tab{id: :admin_document_creator, path: "document-creator", + subtab_display: :when_active, highlight_with_subtabs: false, ...}, + + # Hidden CRUD pages (route exists, no sidebar entry) + %Tab{id: :admin_document_creator_template_new, + path: "document-creator/templates/new", + visible: false, parent: :admin_document_creator, ...}, + %Tab{id: :admin_document_creator_template_edit, + path: "document-creator/templates/:uuid/edit", + visible: false, parent: :admin_document_creator, ...}, + %Tab{id: :admin_document_creator_document_edit, + path: "document-creator/documents/:uuid/edit", + visible: false, parent: :admin_document_creator, ...}, + + # Visible subtabs (appear under parent in sidebar) + %Tab{id: :admin_document_creator_headers, + path: "document-creator/headers", + parent: :admin_document_creator, ...}, + %Tab{id: :admin_document_creator_footers, + path: "document-creator/footers", + parent: :admin_document_creator, ...}, + + # Hidden CRUD pages for subtabs + %Tab{id: :admin_document_creator_header_new, + path: "document-creator/headers/new", + visible: false, parent: :admin_document_creator, ...}, + %Tab{id: :admin_document_creator_header_edit, + path: "document-creator/headers/:uuid/edit", + visible: false, parent: :admin_document_creator, ...}, + # ... footer_new, footer_edit similarly + + # Conditional testing tabs (behind :testing_editors config flag) + # ... only included when config is true + ] +end +``` + +Key patterns: +- **One main tab** visible in the sidebar with `subtab_display: :when_active` +- **Subtabs** for major sections (Headers, Footers) — visible, with `parent` +- **Hidden tabs** for CRUD pages — `visible: false`, still auto-routed +- **Path parameters** work in tab paths: `"document-creator/templates/:uuid/edit"` +- **All tabs** share the same `permission: module_key()` for consistent access control --- @@ -393,15 +628,51 @@ def permission_metadata do end ``` -**How permissions work:** +### How permissions work -- **Owner** — always full access, hard-coded, cannot be restricted -- **Admin** — all 25 permission keys by default, including new module keys -- **Custom roles** — no permissions initially; must be granted explicitly via UI or `Permissions.set_permissions/3` +| Role type | Default access | Can be changed? | +|---|---|---| +| **Owner** | Full access to everything | No — hardcoded, cannot be restricted | +| **Admin** | All permission keys by default | Yes — per key via Admin > Roles | +| **Custom roles** | No permissions initially | Yes — must be granted explicitly | Without `permission_metadata/0` (returns `nil`), the module has no dedicated permission key. Admins and owners still see it; custom roles never will. -**Startup validation:** The registry warns at boot if `permission_metadata.key` ≠ `module_key`. This mismatch causes toggle events and permission checks to use different keys, which is always a bug. +### Checking permissions in code + +```elixir +alias PhoenixKit.Users.Auth.Scope + +# In a LiveView +scope = socket.assigns.phoenix_kit_current_scope + +Scope.has_module_access?(scope, "my_module") # does user have this permission? +Scope.admin?(scope) # is user Owner or Admin? +Scope.system_role?(scope) # Owner, Admin, or User (not custom)? +Scope.owner?(scope) # is user Owner? +Scope.user_roles(scope) # list of role names +``` + +### Access guards on admin tabs + +PhoenixKit's `on_mount` hook automatically checks the `:permission` field on each tab before rendering the LiveView. If the user's role doesn't have the permission, they get a 302 redirect. You don't need manual guards — just set `:permission` correctly. + +For fine-grained checks within a page: + +```elixir + +``` + +### Startup validation + +The registry warns at boot if: +- `permission_metadata.key` does not match `module_key` +- Tabs have no `:permission` field but the module has `permission_metadata` +- Duplicate tab IDs exist across modules + +These are warnings, not crashes — a misconfigured module won't take down the app. --- @@ -418,25 +689,65 @@ def children do end ``` -**Important details:** +### Important details - `static_children/0` is called from `PhoenixKit.Supervisor.init/1` — before the ModuleRegistry GenServer starts. It builds the list directly from the internal module list. This means `children/0` must not rely on the registry being initialized. - Individual module failures in `children/0` are caught by `static_children/0` and logged as warnings — they do not crash the supervisor. - Children start with the PhoenixKit supervisor regardless of whether the module is "enabled". If you only want a process running when enabled, check `enabled?/0` inside the child's `start_link/1` and return `:ignore`. +### Conditional children with optional dependencies + +Guard child specs on optional library availability: + +```elixir +def children do + if Code.ensure_loaded?(ChromicPDF) do + [{MyModule.PdfSupervisor, []}] + else + [] + end +end +``` + +This ensures the module loads even when the optional dependency isn't installed. + +### Worker that respects enabled state + +```elixir +def start_link(_opts) do + if PhoenixKit.Modules.Analytics.enabled?() do + GenServer.start_link(__MODULE__, [], name: __MODULE__) + else + :ignore + end +end +``` + --- ## Route Integration -If your module has admin LiveViews, declare them as tabs with a `live_view` field (for external packages) or register routes directly in `phoenix_kit_web/router.ex` (for internal modules). +### Auto-routing via `live_view` field -**For internal modules:** +For most modules, auto-routing via the tab's `:live_view` field is sufficient. No manual router entries needed. -Add routes directly to the router. The module's admin tab `path` must match the route path. +A tab like: -**For external packages:** +```elixir +%Tab{path: "my-module", live_view: {MyModule.Web.IndexLive, :index}} +``` -Implement `route_module/0` pointing to a module that contains a `phoenix_kit_admin_routes/0` macro: +Generates at compile time: + +```elixir +live "/admin/my-module", MyModule.Web.IndexLive, :index +``` + +inside the admin `live_session` with the admin layout applied. + +### Custom route module + +For complex routing needs (non-LiveView routes, custom pipelines), implement `route_module/0`: ```elixir def route_module, do: PhoenixKitAnalytics.Router @@ -455,41 +766,395 @@ 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 +### Assigns available in admin LiveViews + +PhoenixKit's `on_mount` hooks inject these assigns: + +| Assign | Type | Description | +|---|---|---| +| `@phoenix_kit_current_scope` | `Scope` | Authenticated user's scope (role, permissions) | +| `@current_locale` | `String` | Current locale string (e.g., `"en"`, `"ja"`) | +| `@url_path` | `String` | Current URL path (used for active nav highlighting) | + +Set `@page_title` in `mount/3` — it appears in the browser tab. + +--- -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. +## Navigation System (Paths Module) -**Create a Paths module** for your module to centralize all path construction: +Every path your module generates — in templates, redirects, or LiveView navigation — **must** go through `PhoenixKit.Utils.Routes.path/1`. This handles the configurable URL prefix and locale prefix. + +### Create a Paths module + +Centralize all path construction in one file: ```elixir -defmodule PhoenixKitAnalytics.Paths do +# lib/my_module/paths.ex +defmodule MyModule.Paths do + @moduledoc """ + Centralized path helpers for My Module. + + All navigation paths go through `PhoenixKit.Utils.Routes.path/1`, which + handles the configurable URL prefix and locale prefix automatically. + """ + alias PhoenixKit.Utils.Routes - @base "/admin/analytics" + @base "/admin/my-module" + # ── Main ────────────────────────────────────────────────────────── def index, do: Routes.path(@base) - def show(id), do: Routes.path("#{@base}/#{id}") + + # ── Items ───────────────────────────────────────────────────────── + def item_new, do: Routes.path("#{@base}/items/new") + def item_edit(uuid), do: Routes.path("#{@base}/items/#{uuid}/edit") + def item_show(uuid), do: Routes.path("#{@base}/items/#{uuid}") + + # ── Settings ────────────────────────────────────────────────────── def settings, do: Routes.path("#{@base}/settings") end ``` -Then in templates and LiveViews: +### Usage in LiveViews and templates ```elixir -alias PhoenixKitAnalytics.Paths +alias MyModule.Paths -# Template href: -View +# Redirect after save +{:noreply, redirect(socket, to: Paths.index())} -# Server-side redirect: -redirect(socket, to: Paths.index()) +# Handle not-found +case get_item(uuid) do + nil -> + socket |> put_flash(:error, "Not found") |> redirect(to: Paths.index()) + item -> + assign(socket, item: item) +end +``` + +```heex +Edit +Back ``` -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. +### Tab paths vs template paths -**Why not use relative paths in templates?** +| Where | How to specify paths | +|---|---| +| Tab struct `path` | `"my-module"` (relative — core prepends `/admin/`) | +| Template `href` / `redirect` | `Paths.index()` (wraps `Routes.path/1`) | +| Email URLs | `Routes.url("/path")` (full URL) | -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. +### Why relative paths break + +The browser resolves relative paths relative to the current URL. When locale segments (e.g., `/ja/`) are in the path, relative paths resolve incorrectly. Always use absolute paths via `Routes.path/1`. + +--- + +## Component Reuse + +As modules grow, extract shared UI into reusable function components. This keeps LiveViews focused on business logic. + +### Creating a shared component + +```elixir +# lib/my_module/web/components/item_card.ex +defmodule MyModule.Web.Components.ItemCard do + use Phoenix.Component + + attr :item, :map, required: true + attr :on_edit, :string, default: nil + attr :on_delete, :string, default: nil + + def item_card(assigns) do + ~H""" +
+
+

{@item.name}

+

{@item.description}

+
+ + +
+
+
+ """ + end +end +``` + +### Using components across LiveViews + +```elixir +defmodule MyModule.Web.IndexLive do + use Phoenix.LiveView + import MyModule.Web.Components.ItemCard + + def render(assigns) do + ~H""" +
+ <.item_card :for={item <- @items} item={item} + on_edit="edit_item" on_delete="delete_item" /> +
+ """ + end +end +``` + +### Shared editor panel pattern + +For modules with multiple editor pages that share the same shell: + +```elixir +# lib/my_module/web/components/editor_panel.ex +defmodule MyModule.Web.Components.EditorPanel do + use Phoenix.Component + + attr :id, :string, required: true, doc: "Unique prefix for element IDs" + attr :hook, :string, required: true, doc: "Phoenix hook name" + attr :save_event, :string, required: true, doc: "LiveView event for saving" + attr :show_toolbar, :boolean, default: true + + def editor_panel(assigns) do + ~H""" +
+
+
+
+
+
+ """ + end +end +``` + +Used by multiple LiveViews with different parameters: + +```elixir +# Template editor +import MyModule.Web.Components.EditorPanel +<.editor_panel id="template" hook="TemplateEditor" save_event="save_template" /> + +# Document editor +<.editor_panel id="document" hook="DocumentEditor" save_event="save_document" + show_toolbar={false} /> +``` + +### Multi-step modal component + +```elixir +# lib/my_module/web/components/create_modal.ex +defmodule MyModule.Web.Components.CreateModal do + use Phoenix.Component + + attr :open, :boolean, required: true + attr :step, :string, default: "choose" + attr :templates, :list, default: [] + attr :creating, :boolean, default: false + + def modal(assigns) do + ~H""" + + """ + end +end +``` + +### Component design guidelines + +1. **Use `attr` declarations** — they provide documentation, validation, and compile-time warnings +2. **Use daisyUI semantic classes** — `bg-base-100`, `text-base-content`, `btn btn-primary` (never hardcode colors) +3. **Use `text-base-content/70`** for muted text, not `text-gray-500` +4. **Prefix element IDs** with the component's `@id` to avoid collisions +5. **Pass event names as attrs** (e.g., `on_edit="edit_item"`) — makes components reusable across LiveViews + +--- + +## 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 +``` + +Usage: + +```heex +<.my_scripts /> +
+ ... +
+``` + +**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 inline scripts execute +- Never assume access to `node_modules`, `esbuild`, or the parent app's JS build + +### Base64-encoded JS delivery (for large scripts) + +Large inline `` boundaries** — DOM patching can corrupt the boundary between `` and subsequent HTML +2. **HTML strings inside JS confuse rendering** — JS code containing `'

Title

'` can be parsed as HTML +3. **Browser extensions block inline eval()** — MetaMask's hardened JS, etc. + +The solution is **compile-time base64 encoding**: + +```elixir +defmodule MyModule.Web.Components.MyScripts do + use Phoenix.Component + + # Read and encode JS at compile time + @external_resource Path.join(__DIR__, "my_hooks.js") + @js_source __DIR__ |> Path.join("my_hooks.js") |> File.read!() + @js_base64 Base.encode64(@js_source) + @js_version to_string(:erlang.phash2(@js_source)) + + def my_scripts(assigns) do + assigns = + assigns + |> assign(:js_base64, @js_base64) + |> assign(:js_version, @js_version) + + ~H""" + + + """ + end +end +``` + +The JS source file lives alongside the component: + +```javascript +// lib/my_module/web/components/my_hooks.js +(function() { + "use strict"; + if (window.__MyModuleInitialized) return; + window.__MyModuleInitialized = true; + + window.PhoenixKitHooks = window.PhoenixKitHooks || {}; + window.PhoenixKitHooks.MyEditor = { + mounted() { + this.handleEvent("load-data", (data) => { /* handle server events */ }); + }, + destroyed() { /* cleanup */ } + }; +})(); +``` + +**Why base64 works better:** +- No HTML-significant characters in base64 → no morphdom corruption +- `document.createElement("script")` bypasses extension blocks on `eval()` +- Content hash (`@js_version`) ensures re-execution on LiveView navigations +- `@external_resource` tells Mix to track the JS file for recompilation + +**Editing workflow:** +1. Edit `my_hooks.js` +2. From parent app: `mix deps.compile my_module --force` +3. Restart Phoenix server + +### Loading vendor libraries from CDN + +```javascript +var _libLoaded = false; +var _libCallbacks = []; + +function ensureLibrary(callback) { + if (typeof MyLibrary !== "undefined") { callback(); return; } + _libCallbacks.push(callback); + if (_libLoaded) return; + _libLoaded = true; + + var link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = "https://cdn.jsdelivr.net/npm/my-library@1.0/dist/style.min.css"; + document.head.appendChild(link); + + var script = document.createElement("script"); + script.src = "https://cdn.jsdelivr.net/npm/my-library@1.0/dist/lib.min.js"; + script.onload = function() { + var cbs = _libCallbacks.slice(); + _libCallbacks = []; + cbs.forEach(function(cb) { cb(); }); + }; + document.head.appendChild(script); +} +``` + +### LiveView JS interop + +```javascript +// JS → Elixir +this.pushEvent("save_content", {html: editor.getHtml()}); + +// Elixir → JS +this.handleEvent("load-content", ({html}) => { editor.setContent(html); }); +``` + +```elixir +# In LiveView handle_event +{:noreply, push_event(socket, "load-content", %{html: content.html})} +``` --- @@ -516,7 +1181,6 @@ If disabling your module must also disable a dependent module, do the primary op ```elixir def disable_system do result = Settings.update_boolean_setting_with_module("analytics_enabled", false, "analytics") - # Cascade only after primary succeeds case result do {:ok, _} -> PhoenixKit.Modules.Reports.disable_system() error -> error @@ -548,82 +1212,95 @@ end --- -## External Hex Packages +## Database and Migrations -Creating a standalone `phoenix_kit_analytics` hex package: +### Table naming -**1. Add `phoenix_kit` as a dependency:** +Prefix all tables with `phoenix_kit_` followed by your module key: -```elixir -# mix.exs -{:phoenix_kit, "~> 1.7"} +``` +phoenix_kit_my_module_items +phoenix_kit_my_module_categories ``` -**2. Implement the behaviour:** +### Schemas ```elixir -defmodule PhoenixKitAnalytics do - use PhoenixKit.Module +defmodule MyModule.Schemas.Item do + use Ecto.Schema + import Ecto.Changeset - def module_key, do: "analytics" - def module_name, do: "Analytics" - # ... rest of callbacks + alias PhoenixKit.Schemas.UUIDv7 + + @primary_key {:uuid, UUIDv7, autogenerate: true} + + schema "phoenix_kit_my_module_items" do + field :name, :string + field :status, :string, default: "active" + + belongs_to :user, PhoenixKit.Users.Auth.User, + foreign_key: :user_uuid, references: :uuid, type: UUIDv7 + + timestamps(type: :utc_datetime) + end end ``` -**3. No config needed.** Auto-discovery finds the module via beam scanning because your app depends on `:phoenix_kit`. +### Versioned migrations -**4. Optional explicit config (backwards compat):** +See the hello_world README for a complete migration coordinator example with V01, V02, install task, and upgrade workflow. -```elixir -config :phoenix_kit, :modules, [PhoenixKitAnalytics] -``` +Key rules: +- Version modules are immutable — never edit a shipped V01 +- Use `create_if_not_exists` and `add_if_not_exists` for idempotency +- Track version via SQL comment: `COMMENT ON TABLE {table} IS '{version}'` +- Use `uuid_generate_v7()` for new UUID columns (not `gen_random_uuid()`) -**5. Create a Paths module** to centralize all navigation paths (see [Navigation Paths in Templates](#navigation-paths-in-templates)): +### Foreign keys -```elixir -defmodule PhoenixKitAnalytics.Paths do - alias PhoenixKit.Utils.Routes - @base "/admin/analytics" +Safe to reference: - def index, do: Routes.path(@base) - def show(id), do: Routes.path("#{@base}/#{id}") -end -``` +| Table | Primary key | +|---|---| +| `phoenix_kit_users` | `uuid` (UUIDv7) | +| `phoenix_kit_user_roles` | `uuid` (UUIDv7) | +| `phoenix_kit_settings` | `uuid` (UUIDv7) | -**6. Routes require recompile** after adding the dependency (standard Phoenix constraint). +Always reference `uuid`, not `id` (integer IDs are deprecated). --- -## 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 +**2. Implement the behaviour:** + +```elixir +defmodule PhoenixKitAnalytics do + use PhoenixKit.Module + # ... callbacks 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 ` """ end diff --git a/lib/phoenix_kit_web/components/dashboard/tab_item.ex b/lib/phoenix_kit_web/components/dashboard/tab_item.ex index 01843e39e..ca0cb9f01 100644 --- a/lib/phoenix_kit_web/components/dashboard/tab_item.ex +++ b/lib/phoenix_kit_web/components/dashboard/tab_item.ex @@ -316,7 +316,7 @@ defmodule PhoenixKitWeb.Components.Dashboard.TabItem do """ def path_has_locale_prefix?(path) when is_binary(path) do # Matches: /uk/, /en/, /zh-Hans/, /pt-BR/ etc. - String.match?(path, ~r/^\/[a-z]{2}(-[A-Z][a-z]{2,3})?\//u) + String.match?(path, ~r/^\/[a-z]{2,3}(-[A-Za-z]{2,4})?\//u) end def path_has_locale_prefix?(_), do: false diff --git a/lib/phoenix_kit_web/components/layout_wrapper.ex b/lib/phoenix_kit_web/components/layout_wrapper.ex index 976fcac5f..5f77299a7 100644 --- a/lib/phoenix_kit_web/components/layout_wrapper.ex +++ b/lib/phoenix_kit_web/components/layout_wrapper.ex @@ -204,7 +204,7 @@ defmodule PhoenixKitWeb.Components.LayoutWrapper do end defp strip_locale_prefix(path) do - case Regex.run(~r/^\/[a-z]{2}(-[A-Z]{2})?(\/.*)?$/, path) do + case Regex.run(~r/^\/[a-z]{2,3}(-[A-Za-z]{2,4})?(\/.*)?$/, path) do [_, _locale, rest] when is_binary(rest) -> rest [_, _locale] -> "/" _ -> path @@ -749,10 +749,18 @@ defmodule PhoenixKitWeb.Components.LayoutWrapper do defp get_layout_config do case Config.get(:phoenix_version_strategy, nil) do :modern -> - # Phoenix v1.8+ - get layouts_module and assume :app function - case Config.get(:layouts_module, nil) do - nil -> nil - module -> {module, :app} + # Phoenix v1.8+ - respect explicit layout: config first, then fall back + # to {layouts_module, :app}. The layout: config allows parent apps to + # specify a different layout function (e.g., :full_width instead of :app). + case Config.get(:layout, nil) do + {module, function} when is_atom(module) and is_atom(function) -> + {module, function} + + _ -> + case Config.get(:layouts_module, nil) do + nil -> nil + module -> {module, :app} + end end :legacy -> diff --git a/lib/phoenix_kit_web/components/user_dashboard_nav.ex b/lib/phoenix_kit_web/components/user_dashboard_nav.ex index 47d24aafc..20c4386f6 100644 --- a/lib/phoenix_kit_web/components/user_dashboard_nav.ex +++ b/lib/phoenix_kit_web/components/user_dashboard_nav.ex @@ -230,7 +230,7 @@ defmodule PhoenixKitWeb.Components.UserDashboardNav do # Check if it looks like a locale code defp looks_like_locale?(locale) do - String.length(locale) <= 6 and String.match?(locale, ~r/^[a-z]{2}(-[A-Z]{2})?$/) + String.length(locale) <= 8 and String.match?(locale, ~r/^[a-z]{2,3}(-[A-Za-z]{2,4})?$/) end # Legacy helper - kept for backward compatibility diff --git a/lib/phoenix_kit_web/controllers/redirects/publishing_redirect_controller.ex b/lib/phoenix_kit_web/controllers/redirects/publishing_redirect_controller.ex deleted file mode 100644 index 4151545c6..000000000 --- a/lib/phoenix_kit_web/controllers/redirects/publishing_redirect_controller.ex +++ /dev/null @@ -1,78 +0,0 @@ -defmodule PhoenixKitWeb.Controllers.Redirects.PublishingRedirectController do - @moduledoc """ - Handles redirects from legacy /admin/blogging/* routes to new /admin/publishing/* routes. - - This controller ensures backward compatibility for bookmarked URLs and external links - while the module is being renamed from "blogging" to "publishing". - - All redirects use 301 (Moved Permanently) status to inform browsers and search engines - that the new URLs are the canonical locations. - """ - use PhoenixKitWeb, :controller - - alias PhoenixKit.Utils.Routes - - @doc """ - Redirects /admin/blogging to /admin/publishing - """ - def index(conn, params) do - locale = Map.get(params, "locale") - redirect_to(conn, "/admin/publishing", locale) - end - - @doc """ - Redirects /admin/blogging/:blog to /admin/publishing/:blog - """ - def blog(conn, %{"blog" => blog} = params) do - locale = Map.get(params, "locale") - redirect_to(conn, "/admin/publishing/#{blog}", locale) - end - - @doc """ - Redirects /admin/blogging/:blog/edit to /admin/publishing/:blog/edit - """ - def edit(conn, %{"blog" => blog} = params) do - locale = Map.get(params, "locale") - redirect_to(conn, "/admin/publishing/#{blog}/edit", locale) - end - - @doc """ - Redirects /admin/blogging/:blog/preview to /admin/publishing/:blog/preview - """ - def preview(conn, %{"blog" => blog} = params) do - locale = Map.get(params, "locale") - redirect_to(conn, "/admin/publishing/#{blog}/preview", locale) - end - - @doc """ - Redirects /admin/settings/blogging to /admin/settings/publishing - """ - def settings(conn, params) do - locale = Map.get(params, "locale") - redirect_to(conn, "/admin/settings/publishing", locale) - end - - @doc """ - Redirects /admin/settings/blogging/new to /admin/settings/publishing/new - """ - def new(conn, params) do - locale = Map.get(params, "locale") - redirect_to(conn, "/admin/settings/publishing/new", locale) - end - - @doc """ - Redirects /admin/settings/blogging/:blog/edit to /admin/settings/publishing/:blog/edit - """ - def settings_edit(conn, %{"blog" => blog} = params) do - locale = Map.get(params, "locale") - redirect_to(conn, "/admin/settings/publishing/#{blog}/edit", locale) - end - - defp redirect_to(conn, path, locale) do - full_path = Routes.path(path, locale: locale) - - conn - |> put_status(:moved_permanently) - |> redirect(to: full_path) - end -end diff --git a/lib/phoenix_kit_web/integration.ex b/lib/phoenix_kit_web/integration.ex index d5a9aa69c..8d0abd304 100644 --- a/lib/phoenix_kit_web/integration.ex +++ b/lib/phoenix_kit_web/integration.ex @@ -152,7 +152,7 @@ defmodule PhoenixKitWeb.Integration do # This ensures backward compatibility with old URLs while enforcing base code standard scope "#{unquote(url_prefix)}/:locale", PhoenixKitWeb, - Keyword.put(unquote(opts), :locale, ~r/^[a-z]{2}(?:-[A-Za-z0-9]{2,})?$/) do + Keyword.put(unquote(opts), :locale, ~r/^[a-z]{2,3}(?:-[A-Za-z]{2,4})?$/) do pipe_through [:browser, :phoenix_kit_auto_setup, :phoenix_kit_locale_validation] unquote(block) @@ -1338,7 +1338,7 @@ defmodule PhoenixKitWeb.Integration do # Use a generic locale pattern that accepts any valid language code format # This allows switching to any of the 80+ predefined languages # Actual validation of whether the locale is supported happens in the validation plug - pattern = "[a-z]{2}(?:-[A-Za-z0-9]{2,})?" + pattern = "[a-z]{2,3}(?:-[A-Za-z]{2,4})?" # Call route generators BEFORE quote block (aliases work in this context) # Uses safe_route_call/3 so modules can be safely extracted to separate packages diff --git a/lib/phoenix_kit_web/live/modules/languages.ex b/lib/phoenix_kit_web/live/modules/languages.ex index b0757d0bd..bf0c8e42c 100644 --- a/lib/phoenix_kit_web/live/modules/languages.ex +++ b/lib/phoenix_kit_web/live/modules/languages.ex @@ -101,8 +101,8 @@ defmodule PhoenixKitWeb.Live.Modules.Languages do case result do {:ok, _} -> - # Regenerate all blog caches since language availability changed - regenerate_all_blog_caches() + # Regenerate all publishing group caches since language availability changed + regenerate_all_group_caches() # Reload configuration to get fresh data socket = @@ -178,8 +178,8 @@ defmodule PhoenixKitWeb.Live.Modules.Languages do case result do {:ok, _config} -> - # Regenerate all blog caches since language availability changed - regenerate_all_blog_caches() + # Regenerate all publishing group caches since language availability changed + regenerate_all_group_caches() predefined_lang = Languages.get_predefined_language(code) language_name = (predefined_lang && predefined_lang.name) || code @@ -409,12 +409,12 @@ defmodule PhoenixKitWeb.Live.Modules.Languages do |> Enum.sort_by(fn {country, _} -> country end) end - # Regenerate listing caches for all blogs when language settings change - defp regenerate_all_blog_caches do + # Regenerate listing caches for all publishing groups when language settings change + defp regenerate_all_group_caches do if Publishing.enabled?() do Publishing.list_groups() - |> Enum.each(fn blog -> - ListingCache.regenerate(blog["slug"]) + |> Enum.each(fn group -> + ListingCache.regenerate(group["slug"]) end) end end diff --git a/lib/phoenix_kit_web/routes/blog.ex b/lib/phoenix_kit_web/routes/blog.ex index e6c4949fc..282461c98 100644 --- a/lib/phoenix_kit_web/routes/blog.ex +++ b/lib/phoenix_kit_web/routes/blog.ex @@ -23,13 +23,13 @@ defmodule PhoenixKitWeb.Routes.BlogRoutes do get "/:group", PhoenixKit.Modules.Publishing.Web.Controller, :show, constraints: %{ "group" => ~r/^(?!admin$|assets$|images$|fonts$|js$|css$|favicon)/, - "language" => ~r/^[a-z]{2}$/ + "language" => ~r/^[a-z]{2,3}(-[A-Za-z]{2,4})?$/ } get "/:group/*path", PhoenixKit.Modules.Publishing.Web.Controller, :show, constraints: %{ "group" => ~r/^(?!admin$|assets$|images$|fonts$|js$|css$|favicon)/, - "language" => ~r/^[a-z]{2}$/ + "language" => ~r/^[a-z]{2,3}(-[A-Za-z]{2,4})?$/ } end diff --git a/lib/phoenix_kit_web/routes/publishing.ex b/lib/phoenix_kit_web/routes/publishing.ex index be1b41b42..901761963 100644 --- a/lib/phoenix_kit_web/routes/publishing.ex +++ b/lib/phoenix_kit_web/routes/publishing.ex @@ -2,40 +2,15 @@ defmodule PhoenixKitWeb.Routes.PublishingRoutes do @moduledoc """ Publishing module routes. - Provides route definitions for blog/content management including - both new publishing routes and legacy blogging redirects. + Provides route definitions for content management (publishing groups and posts). """ @doc """ - Returns quoted code for publishing non-LiveView routes (legacy redirects). + Returns quoted code for publishing non-LiveView routes. + Currently a no-op — reserved for future non-LiveView routes. """ - def generate(url_prefix) do + def generate(_url_prefix) do quote do - # Legacy blogging redirects (localized) - alias PhoenixKitWeb.Controllers.Redirects.PublishingRedirectController - - scope "#{unquote(url_prefix)}/:locale" do - pipe_through [:browser] - get "/admin/blogging", PublishingRedirectController, :index - get "/admin/blogging/:blog", PublishingRedirectController, :blog - get "/admin/blogging/:blog/edit", PublishingRedirectController, :edit - get "/admin/blogging/:blog/preview", PublishingRedirectController, :preview - get "/admin/settings/blogging", PublishingRedirectController, :settings - get "/admin/settings/blogging/new", PublishingRedirectController, :new - get "/admin/settings/blogging/:blog/edit", PublishingRedirectController, :settings_edit - end - - # Legacy blogging redirects (non-localized) - scope unquote(url_prefix) do - pipe_through [:browser] - get "/admin/blogging", PublishingRedirectController, :index - get "/admin/blogging/:blog", PublishingRedirectController, :blog - get "/admin/blogging/:blog/edit", PublishingRedirectController, :edit - get "/admin/blogging/:blog/preview", PublishingRedirectController, :preview - get "/admin/settings/blogging", PublishingRedirectController, :settings - get "/admin/settings/blogging/new", PublishingRedirectController, :new - get "/admin/settings/blogging/:blog/edit", PublishingRedirectController, :settings_edit - end end end @@ -47,6 +22,15 @@ defmodule PhoenixKitWeb.Routes.PublishingRoutes do live "/admin/publishing", PhoenixKit.Modules.Publishing.Web.Index, :index, as: :publishing_index_localized + # Literal path routes MUST come before :group param routes + live "/admin/publishing/new-group", PhoenixKit.Modules.Publishing.Web.New, :new, + as: :publishing_new_group_localized + + live "/admin/publishing/edit-group/:group", + PhoenixKit.Modules.Publishing.Web.Edit, + :edit, + as: :publishing_edit_group_localized + live "/admin/publishing/:group", PhoenixKit.Modules.Publishing.Web.Listing, :group, as: :publishing_group_localized @@ -79,14 +63,6 @@ defmodule PhoenixKitWeb.Routes.PublishingRoutes do live "/admin/settings/publishing", PhoenixKit.Modules.Publishing.Web.Settings, :index, as: :publishing_settings_localized - - live "/admin/settings/publishing/new", PhoenixKit.Modules.Publishing.Web.New, :new, - as: :publishing_new_localized - - live "/admin/settings/publishing/:group/edit", - PhoenixKit.Modules.Publishing.Web.Edit, - :edit, - as: :publishing_edit_localized end end @@ -98,6 +74,15 @@ defmodule PhoenixKitWeb.Routes.PublishingRoutes do live "/admin/publishing", PhoenixKit.Modules.Publishing.Web.Index, :index, as: :publishing_index + # Literal path routes MUST come before :group param routes + live "/admin/publishing/new-group", PhoenixKit.Modules.Publishing.Web.New, :new, + as: :publishing_new_group + + live "/admin/publishing/edit-group/:group", + PhoenixKit.Modules.Publishing.Web.Edit, + :edit, + as: :publishing_edit_group + live "/admin/publishing/:group", PhoenixKit.Modules.Publishing.Web.Listing, :group, as: :publishing_group @@ -130,14 +115,6 @@ defmodule PhoenixKitWeb.Routes.PublishingRoutes do live "/admin/settings/publishing", PhoenixKit.Modules.Publishing.Web.Settings, :index, as: :publishing_settings - - live "/admin/settings/publishing/new", PhoenixKit.Modules.Publishing.Web.New, :new, - as: :publishing_new - - live "/admin/settings/publishing/:group/edit", - PhoenixKit.Modules.Publishing.Web.Edit, - :edit, - as: :publishing_edit end end end diff --git a/test/modules/publishing/facade_test.exs b/test/modules/publishing/facade_test.exs new file mode 100644 index 000000000..805a5cb8b --- /dev/null +++ b/test/modules/publishing/facade_test.exs @@ -0,0 +1,173 @@ +defmodule PhoenixKit.Modules.Publishing.FacadeTest do + @moduledoc """ + Tests that all public functions are properly delegated through the facade. + Verifies every function in Publishing.* submodules is accessible via Publishing. + """ + use ExUnit.Case, async: true + + alias PhoenixKit.Modules.Publishing + + # ============================================================================ + # Group Delegations + # ============================================================================ + + describe "group delegations" do + test "all group functions are exported from facade" do + assert function_exported?(Publishing, :list_groups, 0) + assert function_exported?(Publishing, :get_group, 1) + assert function_exported?(Publishing, :add_group, 1) + assert function_exported?(Publishing, :add_group, 2) + assert function_exported?(Publishing, :remove_group, 1) + assert function_exported?(Publishing, :remove_group, 2) + assert function_exported?(Publishing, :update_group, 2) + assert function_exported?(Publishing, :trash_group, 1) + assert function_exported?(Publishing, :group_name, 1) + assert function_exported?(Publishing, :get_group_mode, 1) + assert function_exported?(Publishing, :preset_types, 0) + assert function_exported?(Publishing, :valid_types, 0) + end + end + + # ============================================================================ + # Version Delegations + # ============================================================================ + + describe "version delegations" do + test "all version functions are exported from facade" do + assert function_exported?(Publishing, :list_versions, 2) + assert function_exported?(Publishing, :get_published_version, 2) + assert function_exported?(Publishing, :get_version_status, 4) + assert function_exported?(Publishing, :get_version_metadata, 4) + assert function_exported?(Publishing, :create_new_version, 2) + assert function_exported?(Publishing, :create_new_version, 3) + assert function_exported?(Publishing, :create_new_version, 4) + assert function_exported?(Publishing, :publish_version, 3) + assert function_exported?(Publishing, :publish_version, 4) + assert function_exported?(Publishing, :create_version_from, 3) + assert function_exported?(Publishing, :create_version_from, 4) + assert function_exported?(Publishing, :create_version_from, 5) + assert function_exported?(Publishing, :delete_version, 3) + assert function_exported?(Publishing, :broadcast_version_created, 3) + end + end + + # ============================================================================ + # Translation Delegations + # ============================================================================ + + describe "translation delegations" do + test "all translation functions are exported from facade" do + assert function_exported?(Publishing, :get_post_primary_language, 2) + assert function_exported?(Publishing, :get_post_primary_language, 3) + assert function_exported?(Publishing, :check_primary_language_status, 2) + assert function_exported?(Publishing, :update_post_primary_language, 3) + assert function_exported?(Publishing, :update_posts_primary_language, 1) + assert function_exported?(Publishing, :count_posts_needing_language_update, 1) + assert function_exported?(Publishing, :add_language_to_post, 3) + assert function_exported?(Publishing, :add_language_to_post, 4) + assert function_exported?(Publishing, :add_language_to_db, 4) + assert function_exported?(Publishing, :delete_language, 3) + assert function_exported?(Publishing, :delete_language, 4) + assert function_exported?(Publishing, :set_translation_status, 5) + assert function_exported?(Publishing, :translate_post_to_all_languages, 2) + assert function_exported?(Publishing, :translate_post_to_all_languages, 3) + end + end + + # ============================================================================ + # Stale Fixer Delegations + # ============================================================================ + + describe "stale fixer delegations" do + test "all stale fixer functions are exported from facade" do + assert function_exported?(Publishing, :fix_stale_group, 1) + assert function_exported?(Publishing, :fix_stale_post, 1) + assert function_exported?(Publishing, :fix_stale_version, 1) + assert function_exported?(Publishing, :fix_stale_content, 1) + assert function_exported?(Publishing, :fix_all_stale_values, 0) + assert function_exported?(Publishing, :reconcile_post_status, 1) + end + end + + # ============================================================================ + # Cache Delegations + # ============================================================================ + + describe "cache delegations" do + test "all cache functions are exported from facade" do + assert function_exported?(Publishing, :regenerate_cache, 1) + assert function_exported?(Publishing, :invalidate_cache, 1) + assert function_exported?(Publishing, :cache_exists?, 1) + assert function_exported?(Publishing, :find_cached_post, 2) + assert function_exported?(Publishing, :find_cached_post_by_path, 3) + end + end + + # ============================================================================ + # Language Helper Delegations + # ============================================================================ + + describe "language helper delegations" do + test "all language helper functions are exported from facade" do + assert function_exported?(Publishing, :get_language_info, 1) + assert function_exported?(Publishing, :enabled_language_codes, 0) + assert function_exported?(Publishing, :get_primary_language, 0) + assert function_exported?(Publishing, :language_enabled?, 2) + assert function_exported?(Publishing, :get_display_code, 2) + assert function_exported?(Publishing, :order_languages_for_display, 2) + assert function_exported?(Publishing, :order_languages_for_display, 3) + end + end + + # ============================================================================ + # Slug Helper Delegations + # ============================================================================ + + describe "slug helper delegations" do + test "all slug helper functions are exported from facade" do + assert function_exported?(Publishing, :validate_slug, 1) + assert function_exported?(Publishing, :slug_exists?, 2) + assert function_exported?(Publishing, :generate_unique_slug, 2) + assert function_exported?(Publishing, :generate_unique_slug, 3) + assert function_exported?(Publishing, :generate_unique_slug, 4) + assert function_exported?(Publishing, :validate_url_slug, 4) + end + end + + # ============================================================================ + # Shared Helpers on Facade + # ============================================================================ + + describe "shared helpers on facade" do + test "slugify is accessible" do + assert Publishing.slugify("Hello World") == "hello-world" + end + + test "valid_slug? is accessible" do + assert Publishing.valid_slug?("hello-world") + refute Publishing.valid_slug?("") + end + + test "fetch_option is accessible" do + assert Publishing.fetch_option(%{key: "val"}, :key) == "val" + end + + test "audit_metadata is accessible" do + assert Publishing.audit_metadata(nil, :create) == %{} + end + + test "db_post? is accessible" do + assert Publishing.db_post?(%{uuid: "test"}) + refute Publishing.db_post?(%{}) + end + + test "should_create_new_version? always returns false" do + refute Publishing.should_create_new_version?(%{}, %{}, "en") + end + + test "module behaviour functions" do + assert Publishing.module_key() == "publishing" + assert Publishing.module_name() == "Publishing" + end + end +end diff --git a/test/modules/publishing/groups_test.exs b/test/modules/publishing/groups_test.exs new file mode 100644 index 000000000..9ad6c9846 --- /dev/null +++ b/test/modules/publishing/groups_test.exs @@ -0,0 +1,94 @@ +defmodule PhoenixKit.Modules.Publishing.GroupsTest do + use ExUnit.Case, async: true + + alias PhoenixKit.Modules.Publishing.Groups + + # ============================================================================ + # preset_types/0 + # ============================================================================ + + describe "preset_types/0" do + test "returns list of preset type maps" do + types = Groups.preset_types() + assert is_list(types) + assert length(types) == 3 + + labels = Enum.map(types, & &1.type) + assert "blog" in labels + assert "faq" in labels + assert "legal" in labels + end + + test "each preset has type, label, item_singular, item_plural" do + for preset <- Groups.preset_types() do + assert is_binary(preset.type) + assert is_binary(preset.label) + assert is_binary(preset.item_singular) + assert is_binary(preset.item_plural) + end + end + + test "blog preset has post/posts item names" do + blog = Enum.find(Groups.preset_types(), &(&1.type == "blog")) + assert blog.item_singular == "post" + assert blog.item_plural == "posts" + end + + test "faq preset has question/questions item names" do + faq = Enum.find(Groups.preset_types(), &(&1.type == "faq")) + assert faq.item_singular == "question" + assert faq.item_plural == "questions" + end + + test "legal preset has document/documents item names" do + legal = Enum.find(Groups.preset_types(), &(&1.type == "legal")) + assert legal.item_singular == "document" + assert legal.item_plural == "documents" + end + end + + # ============================================================================ + # valid_types/0 + # ============================================================================ + + describe "valid_types/0" do + test "returns list of valid type strings" do + types = Groups.valid_types() + assert is_list(types) + assert "blog" in types + assert "faq" in types + assert "legal" in types + assert "custom" in types + end + + test "includes custom type" do + assert "custom" in Groups.valid_types() + end + end + + # ============================================================================ + # fetch_option/2 + # ============================================================================ + + describe "fetch_option/2" do + test "fetches atom key from map" do + assert Groups.fetch_option(%{mode: "slug"}, :mode) == "slug" + end + + test "fetches string key from map as fallback" do + assert Groups.fetch_option(%{"mode" => "slug"}, :mode) == "slug" + end + + test "fetches from keyword list" do + assert Groups.fetch_option([mode: "slug"], :mode) == "slug" + end + + test "returns nil for missing key" do + assert Groups.fetch_option(%{}, :mode) == nil + end + + test "returns nil for non-container" do + assert Groups.fetch_option(nil, :mode) == nil + end + end +end diff --git a/test/modules/publishing/mapper_test.exs b/test/modules/publishing/mapper_test.exs index 5716a259e..fe9ec291e 100644 --- a/test/modules/publishing/mapper_test.exs +++ b/test/modules/publishing/mapper_test.exs @@ -67,17 +67,17 @@ defmodule PhoenixKit.Modules.Publishing.DBStorage.MapperTest do end # ============================================================================ - # to_legacy_map/5 + # to_post_map/5 # ============================================================================ - describe "to_legacy_map/5" do - test "converts DB records to legacy map format" do + describe "to_post_map/5" do + test "converts DB records to post map format" do group = build_group() post = build_post(group) version = build_version(post) content = build_content(version) - result = Mapper.to_legacy_map(post, version, content, [content], [version]) + result = Mapper.to_post_map(post, version, content, [content], [version]) assert result.uuid == post.uuid assert result.group == "blog" @@ -97,7 +97,7 @@ defmodule PhoenixKit.Modules.Publishing.DBStorage.MapperTest do es_content = build_content(version, %{language: "es", status: "draft"}) result = - Mapper.to_legacy_map(post, version, en_content, [en_content, es_content], [version]) + Mapper.to_post_map(post, version, en_content, [en_content, es_content], [version]) assert result.available_languages == ["en", "es"] assert result.language_statuses == %{"en" => "published", "es" => "draft"} @@ -110,7 +110,7 @@ defmodule PhoenixKit.Modules.Publishing.DBStorage.MapperTest do v2 = build_version(post, %{version_number: 2, status: "published"}) content = build_content(v2) - result = Mapper.to_legacy_map(post, v2, content, [content], [v1, v2]) + result = Mapper.to_post_map(post, v2, content, [content], [v1, v2]) assert result.available_versions == [1, 2] assert result.version_statuses == %{1 => "archived", 2 => "published"} @@ -129,7 +129,7 @@ defmodule PhoenixKit.Modules.Publishing.DBStorage.MapperTest do version = build_version(post) content = build_content(version) - result = Mapper.to_legacy_map(post, version, content, [content], [version]) + result = Mapper.to_post_map(post, version, content, [content], [version]) assert result.mode == :timestamp assert result.date == ~D[2025-06-15] @@ -142,7 +142,7 @@ defmodule PhoenixKit.Modules.Publishing.DBStorage.MapperTest do version = build_version(post) content = build_content(version, %{url_slug: nil}) - result = Mapper.to_legacy_map(post, version, content, [content], [version]) + result = Mapper.to_post_map(post, version, content, [content], [version]) assert result.url_slug == "hello-world" end @@ -153,7 +153,7 @@ defmodule PhoenixKit.Modules.Publishing.DBStorage.MapperTest do version = build_version(post) content = build_content(version, %{url_slug: "custom-url"}) - result = Mapper.to_legacy_map(post, version, content, [content], [version]) + result = Mapper.to_post_map(post, version, content, [content], [version]) assert result.url_slug == "custom-url" end @@ -172,7 +172,7 @@ defmodule PhoenixKit.Modules.Publishing.DBStorage.MapperTest do } }) - result = Mapper.to_legacy_map(post, version, content, [content], [version]) + result = Mapper.to_post_map(post, version, content, [content], [version]) assert result.metadata.title == "Hello World" assert result.metadata.description == "A test post" @@ -192,10 +192,91 @@ defmodule PhoenixKit.Modules.Publishing.DBStorage.MapperTest do en = build_content(version, %{language: "en", url_slug: "hello"}) es = build_content(version, %{language: "es", url_slug: "hola"}) - result = Mapper.to_legacy_map(post, version, en, [en, es], [version]) + result = Mapper.to_post_map(post, version, en, [en, es], [version]) assert result.language_slugs == %{"en" => "hello", "es" => "hola"} end + + test "builds version_dates from all versions" do + group = build_group() + post = build_post(group) + v1 = build_version(post, %{version_number: 1, inserted_at: ~U[2025-06-10 10:00:00Z]}) + v2 = build_version(post, %{version_number: 2, inserted_at: ~U[2025-06-15 14:30:00Z]}) + content = build_content(v2) + + result = Mapper.to_post_map(post, v2, content, [content], [v1, v2]) + + assert result.version_dates == %{ + 1 => "2025-06-10T10:00:00Z", + 2 => "2025-06-15T14:30:00Z" + } + end + + test "builds language_previous_slugs from all contents" do + group = build_group() + post = build_post(group) + version = build_version(post) + + en = + build_content(version, %{ + language: "en", + data: %{"previous_url_slugs" => ["old-hello"]} + }) + + es = build_content(version, %{language: "es", data: %{}}) + + result = Mapper.to_post_map(post, version, en, [en, es], [version]) + + assert result.language_previous_slugs["en"] == ["old-hello"] + assert result.language_previous_slugs["es"] == [] + end + + test "merges published_language_statuses via opts" do + group = build_group() + post = build_post(group) + version = build_version(post) + en = build_content(version, %{language: "en", status: "draft"}) + es = build_content(version, %{language: "es", status: "draft"}) + + result = + Mapper.to_post_map(post, version, en, [en, es], [version], + published_language_statuses: %{"en" => "published"} + ) + + assert result.language_statuses["en"] == "published" + assert result.language_statuses["es"] == "draft" + end + + test "group slug is nil when group is not preloaded" do + group = build_group() + + post = + build_post(group, %{ + group: %Ecto.Association.NotLoaded{ + __field__: :group, + __cardinality__: :one, + __owner__: PublishingPost + } + }) + + version = build_version(post) + content = build_content(version) + + result = Mapper.to_post_map(post, version, content, [content], [version]) + + assert result.group == nil + end + + test "url_slug falls back to post slug when empty string" do + group = build_group() + post = build_post(group) + version = build_version(post) + content = build_content(version, %{url_slug: ""}) + + result = Mapper.to_post_map(post, version, content, [content], [version]) + + assert result.url_slug == "hello-world" + end end # ============================================================================ @@ -271,5 +352,137 @@ defmodule PhoenixKit.Modules.Publishing.DBStorage.MapperTest do assert result.metadata.title == nil assert result.content == nil end + + test "falls back to first content when primary language not found" do + group = build_group() + post = build_post(group, %{primary_language: "en"}) + version = build_version(post) + es = build_content(version, %{language: "es", title: "Titulo Espanol"}) + + result = Mapper.to_listing_map(post, version, [es], [version]) + + assert result.metadata.title == "Titulo Espanol" + end + + test "uses description as excerpt fallback when no custom excerpt" do + group = build_group() + post = build_post(group) + version = build_version(post) + + content = + build_content(version, %{ + content: "Full content here", + data: %{"description" => "A meta description"} + }) + + result = Mapper.to_listing_map(post, version, [content], [version]) + + assert result.content == "A meta description" + end + + test "builds language_titles and language_excerpts" do + group = build_group() + post = build_post(group) + version = build_version(post) + + en = + build_content(version, %{ + language: "en", + title: "Hello", + data: %{"excerpt" => "EN excerpt"} + }) + + es = + build_content(version, %{ + language: "es", + title: "Hola", + data: %{"excerpt" => "ES excerpt"} + }) + + result = Mapper.to_listing_map(post, version, [en, es], [version]) + + assert result.language_titles == %{"en" => "Hello", "es" => "Hola"} + assert result.language_excerpts == %{"en" => "EN excerpt", "es" => "ES excerpt"} + end + + test "builds version_dates from all versions" do + group = build_group() + post = build_post(group) + v1 = build_version(post, %{version_number: 1, inserted_at: ~U[2025-06-10 10:00:00Z]}) + v2 = build_version(post, %{version_number: 2, inserted_at: ~U[2025-06-15 14:30:00Z]}) + content = build_content(v2) + + result = Mapper.to_listing_map(post, v2, [content], [v1, v2]) + + assert result.version_dates == %{ + 1 => "2025-06-10T10:00:00Z", + 2 => "2025-06-15T14:30:00Z" + } + end + + test "url_slug falls back to post slug when content url_slug is nil" do + group = build_group() + post = build_post(group) + version = build_version(post) + content = build_content(version, %{url_slug: nil}) + + result = Mapper.to_listing_map(post, version, [content], [version]) + + assert result.url_slug == "hello-world" + end + + test "merges published_language_statuses via opts" do + group = build_group() + post = build_post(group) + version = build_version(post) + en = build_content(version, %{language: "en", status: "draft"}) + + result = + Mapper.to_listing_map(post, version, [en], [version], + published_language_statuses: %{"en" => "published"} + ) + + assert result.language_statuses["en"] == "published" + end + + test "version defaults to 1 when version is nil" do + group = build_group() + post = build_post(group) + version = build_version(post) + content = build_content(version) + + result = Mapper.to_listing_map(post, nil, [content], [version]) + + assert result.version == 1 + end + + test "builds available_versions and version_statuses" do + group = build_group() + post = build_post(group) + v1 = build_version(post, %{version_number: 1, status: "archived"}) + v2 = build_version(post, %{version_number: 2, status: "published"}) + content = build_content(v2) + + result = Mapper.to_listing_map(post, v2, [content], [v1, v2]) + + assert result.available_versions == [1, 2] + assert result.version_statuses == %{1 => "archived", 2 => "published"} + end + + test "extracts first non-heading paragraph when no excerpt or description" do + group = build_group() + post = build_post(group) + version = build_version(post) + + content = + build_content(version, %{ + content: "## Heading\n\nActual paragraph here.\n\nMore content.", + data: %{} + }) + + result = Mapper.to_listing_map(post, version, [content], [version]) + + assert result.content == "Actual paragraph here." + end end end diff --git a/test/modules/publishing/posts_test.exs b/test/modules/publishing/posts_test.exs new file mode 100644 index 000000000..82650b78b --- /dev/null +++ b/test/modules/publishing/posts_test.exs @@ -0,0 +1,106 @@ +defmodule PhoenixKit.Modules.Publishing.PostsTest do + use ExUnit.Case, async: true + + alias PhoenixKit.Modules.Publishing.Posts + + # ============================================================================ + # db_post?/1 + # ============================================================================ + + describe "db_post?/1" do + test "returns true when post has uuid" do + assert Posts.db_post?(%{uuid: "019cce93-ed2e-7e1b-9e62-af160709fd94"}) + end + + test "returns false when uuid is nil" do + refute Posts.db_post?(%{uuid: nil}) + end + + test "returns false when no uuid key" do + refute Posts.db_post?(%{slug: "test"}) + end + end + + # ============================================================================ + # extract_slug_version_and_language/2 + # ============================================================================ + + describe "extract_slug_version_and_language/2" do + test "extracts slug only" do + assert {"hello-world", nil, nil} = + Posts.extract_slug_version_and_language("blog", "hello-world") + end + + test "extracts slug and version" do + assert {"hello-world", 2, nil} = + Posts.extract_slug_version_and_language("blog", "hello-world/v2") + end + + test "extracts slug, version, and language" do + assert {"hello-world", 2, "en"} = + Posts.extract_slug_version_and_language("blog", "hello-world/v2/en") + end + + test "extracts slug and language without version" do + assert {"hello-world", nil, "en"} = + Posts.extract_slug_version_and_language("blog", "hello-world/en") + end + + test "handles nil identifier" do + assert {"", nil, nil} = Posts.extract_slug_version_and_language("blog", nil) + end + + test "drops group prefix when present" do + assert {"hello-world", 1, "en"} = + Posts.extract_slug_version_and_language("blog", "blog/hello-world/v1/en") + end + + test "handles leading slash" do + assert {"hello-world", nil, nil} = + Posts.extract_slug_version_and_language("blog", "/hello-world") + end + + test "does not drop group prefix when it's the only element" do + assert {"blog", nil, nil} = + Posts.extract_slug_version_and_language("blog", "blog") + end + + test "handles empty string identifier" do + assert {"", nil, nil} = + Posts.extract_slug_version_and_language("blog", "") + end + end + + # ============================================================================ + # Facade delegation consistency + # ============================================================================ + + describe "facade consistency" do + test "all public functions are accessible through Publishing facade" do + alias PhoenixKit.Modules.Publishing + + # These should all be delegated and callable (they may fail at DB level, + # but the delegation should not raise UndefinedFunctionError) + assert function_exported?(Publishing, :list_posts, 1) + assert function_exported?(Publishing, :list_posts, 2) + assert function_exported?(Publishing, :create_post, 1) + assert function_exported?(Publishing, :create_post, 2) + assert function_exported?(Publishing, :read_post, 2) + assert function_exported?(Publishing, :read_post, 3) + assert function_exported?(Publishing, :read_post, 4) + assert function_exported?(Publishing, :read_post_by_uuid, 1) + assert function_exported?(Publishing, :read_post_by_uuid, 2) + assert function_exported?(Publishing, :read_post_by_uuid, 3) + assert function_exported?(Publishing, :update_post, 3) + assert function_exported?(Publishing, :update_post, 4) + assert function_exported?(Publishing, :trash_post, 2) + assert function_exported?(Publishing, :count_posts_on_date, 2) + assert function_exported?(Publishing, :list_times_on_date, 2) + assert function_exported?(Publishing, :find_by_url_slug, 3) + assert function_exported?(Publishing, :find_by_previous_url_slug, 3) + assert function_exported?(Publishing, :db_post?, 1) + assert function_exported?(Publishing, :should_create_new_version?, 3) + assert function_exported?(Publishing, :extract_slug_version_and_language, 2) + end + end +end diff --git a/test/modules/publishing/publishing_api_test.exs b/test/modules/publishing/publishing_api_test.exs index b4d732c9d..36f60babd 100644 --- a/test/modules/publishing/publishing_api_test.exs +++ b/test/modules/publishing/publishing_api_test.exs @@ -52,6 +52,15 @@ defmodule PhoenixKit.Modules.Publishing.PublishingAPITest do PhoenixKit.Modules.Publishing.Workers.MigratePrimaryLanguageWorker ) end + + test "Refactored submodules are defined" do + assert Code.ensure_loaded?(PhoenixKit.Modules.Publishing.Groups) + assert Code.ensure_loaded?(PhoenixKit.Modules.Publishing.Posts) + assert Code.ensure_loaded?(PhoenixKit.Modules.Publishing.Versions) + assert Code.ensure_loaded?(PhoenixKit.Modules.Publishing.TranslationManager) + assert Code.ensure_loaded?(PhoenixKit.Modules.Publishing.StaleFixer) + assert Code.ensure_loaded?(PhoenixKit.Modules.Publishing.Shared) + end end # ============================================================================ diff --git a/test/modules/publishing/schema_test.exs b/test/modules/publishing/schema_test.exs index 73e7ec8d5..51777cde3 100644 --- a/test/modules/publishing/schema_test.exs +++ b/test/modules/publishing/schema_test.exs @@ -63,7 +63,7 @@ defmodule PhoenixKit.Modules.Publishing.SchemaTest do test "data JSONB accessors return defaults" do group = %PublishingGroup{data: %{}} - assert PublishingGroup.get_type(group) == "blogging" + assert PublishingGroup.get_type(group) == "blog" assert PublishingGroup.get_item_singular(group) == "Post" assert PublishingGroup.get_item_plural(group) == "Posts" assert PublishingGroup.get_description(group) == nil @@ -154,7 +154,7 @@ defmodule PhoenixKit.Modules.Publishing.SchemaTest do end test "changeset accepts valid statuses" do - for status <- ["draft", "published", "archived", "scheduled"] do + for status <- ["draft", "published", "archived", "trashed"] do attrs = %{ group_uuid: UUIDv7.generate(), slug: "test", @@ -163,13 +163,6 @@ defmodule PhoenixKit.Modules.Publishing.SchemaTest do primary_language: "en" } - attrs = - if status == "scheduled" do - Map.put(attrs, :scheduled_at, DateTime.add(DateTime.utc_now(), 3600)) - else - attrs - end - changeset = PublishingPost.changeset(%PublishingPost{}, attrs) assert changeset.valid?, @@ -177,24 +170,22 @@ defmodule PhoenixKit.Modules.Publishing.SchemaTest do end end - test "changeset requires scheduled_at when status is scheduled" do + test "changeset rejects invalid status" do changeset = PublishingPost.changeset(%PublishingPost{}, %{ group_uuid: UUIDv7.generate(), slug: "test", - status: "scheduled", + status: "invalid", mode: "slug", primary_language: "en" }) refute changeset.valid? - assert "must be set when status is scheduled" in errors_on(changeset, :scheduled_at) end test "status helpers" do published = %PublishingPost{status: "published"} draft = %PublishingPost{status: "draft"} - scheduled = %PublishingPost{status: "scheduled"} archived = %PublishingPost{status: "archived"} assert PublishingPost.published?(published) @@ -202,9 +193,7 @@ defmodule PhoenixKit.Modules.Publishing.SchemaTest do assert PublishingPost.draft?(draft) refute PublishingPost.draft?(published) - - assert PublishingPost.scheduled?(scheduled) - refute PublishingPost.scheduled?(archived) + refute PublishingPost.draft?(archived) end test "data JSONB accessors return defaults" do diff --git a/test/modules/publishing/shared_test.exs b/test/modules/publishing/shared_test.exs new file mode 100644 index 000000000..6ad6ed021 --- /dev/null +++ b/test/modules/publishing/shared_test.exs @@ -0,0 +1,251 @@ +defmodule PhoenixKit.Modules.Publishing.SharedTest do + use ExUnit.Case, async: true + + alias PhoenixKit.Modules.Publishing.Shared + + # ============================================================================ + # uuid_format?/1 + # ============================================================================ + + describe "uuid_format?/1" do + test "returns true for valid UUIDv7" do + assert Shared.uuid_format?("019cce93-ed2e-7e1b-9e62-af160709fd94") + end + + test "returns false for non-UUID string" do + refute Shared.uuid_format?("not-a-uuid") + refute Shared.uuid_format?("hello") + refute Shared.uuid_format?("") + end + + test "returns false for nil" do + refute Shared.uuid_format?(nil) + end + + test "returns false for non-string types" do + refute Shared.uuid_format?(123) + refute Shared.uuid_format?(:atom) + end + end + + # ============================================================================ + # fetch_option/2 + # ============================================================================ + + describe "fetch_option/2" do + test "fetches atom key from map" do + assert Shared.fetch_option(%{title: "Hello"}, :title) == "Hello" + end + + test "fetches string key from map as fallback" do + assert Shared.fetch_option(%{"title" => "Hello"}, :title) == "Hello" + end + + test "fetches from keyword list" do + assert Shared.fetch_option([title: "Hello"], :title) == "Hello" + end + + test "returns nil for missing key in map" do + assert Shared.fetch_option(%{other: "value"}, :title) == nil + end + + test "returns nil for missing key in keyword list" do + assert Shared.fetch_option([other: "value"], :title) == nil + end + + test "returns nil for non-map non-list" do + assert Shared.fetch_option("string", :title) == nil + assert Shared.fetch_option(nil, :title) == nil + assert Shared.fetch_option(123, :title) == nil + end + end + + # ============================================================================ + # parse_timestamp_path/1 + # ============================================================================ + + describe "parse_timestamp_path/1" do + test "parses date only" do + assert {:ok, ~D[2025-12-09], nil, nil, nil} = + Shared.parse_timestamp_path("2025-12-09") + end + + test "parses date and time" do + assert {:ok, ~D[2025-12-09], ~T[15:30:00], nil, nil} = + Shared.parse_timestamp_path("2025-12-09/15:30") + end + + test "parses date, time, and version" do + assert {:ok, ~D[2025-12-09], ~T[15:30:00], 2, nil} = + Shared.parse_timestamp_path("2025-12-09/15:30/v2") + end + + test "parses date, time, and language" do + assert {:ok, ~D[2025-12-09], ~T[15:30:00], nil, "en"} = + Shared.parse_timestamp_path("2025-12-09/15:30/en") + end + + test "parses date, time, version, and language" do + assert {:ok, ~D[2025-12-09], ~T[15:30:00], 3, "fr"} = + Shared.parse_timestamp_path("2025-12-09/15:30/v3/fr") + end + + test "strips leading slash" do + assert {:ok, ~D[2025-12-09], ~T[15:30:00], nil, nil} = + Shared.parse_timestamp_path("/2025-12-09/15:30") + end + + test "returns nil for non-date strings" do + assert Shared.parse_timestamp_path("not-a-date") == nil + assert Shared.parse_timestamp_path("hello/world") == nil + end + + test "returns nil for invalid date" do + assert Shared.parse_timestamp_path("2025-13-45") == nil + end + + test "returns nil for invalid time" do + assert Shared.parse_timestamp_path("2025-12-09/25:99") == nil + end + + test "returns nil for empty string" do + assert Shared.parse_timestamp_path("") == nil + end + end + + # ============================================================================ + # parse_time/1 + # ============================================================================ + + describe "parse_time/1" do + test "parses valid HH:MM time" do + assert {:ok, ~T[15:30:00]} = Shared.parse_time("15:30") + assert {:ok, ~T[00:00:00]} = Shared.parse_time("00:00") + assert {:ok, ~T[23:59:00]} = Shared.parse_time("23:59") + end + + test "returns error for invalid time" do + assert match?({:error, _}, Shared.parse_time("25:00")) + assert match?(:error, Shared.parse_time("abc")) + assert match?(:error, Shared.parse_time("")) + end + + test "returns error for non-string" do + assert match?(:error, Shared.parse_time(nil)) + assert match?(:error, Shared.parse_time(123)) + end + end + + # ============================================================================ + # extract_version_from_parts/1 + # ============================================================================ + + describe "extract_version_from_parts/1" do + test "extracts version from v-prefixed part" do + assert {1, ["en"]} = Shared.extract_version_from_parts(["v1", "en"]) + assert {42, []} = Shared.extract_version_from_parts(["v42"]) + end + + test "returns nil version for non-version parts" do + assert {nil, ["en"]} = Shared.extract_version_from_parts(["en"]) + assert {nil, ["slug"]} = Shared.extract_version_from_parts(["slug"]) + end + + test "handles empty list" do + assert {nil, []} = Shared.extract_version_from_parts([]) + end + end + + # ============================================================================ + # parse_version_segment/1 + # ============================================================================ + + describe "parse_version_segment/1" do + test "parses v-prefixed version numbers" do + assert {:ok, 1} = Shared.parse_version_segment("v1") + assert {:ok, 10} = Shared.parse_version_segment("v10") + assert {:ok, 999} = Shared.parse_version_segment("v999") + end + + test "returns error for non-version strings" do + assert :error = Shared.parse_version_segment("en") + assert :error = Shared.parse_version_segment("version1") + assert :error = Shared.parse_version_segment("v") + assert :error = Shared.parse_version_segment("") + end + + test "returns error for non-string" do + assert :error = Shared.parse_version_segment(nil) + assert :error = Shared.parse_version_segment(123) + end + end + + # ============================================================================ + # should_regenerate_cache?/1 + # ============================================================================ + + describe "should_regenerate_cache?/1" do + test "returns true for timestamp mode" do + assert Shared.should_regenerate_cache?(%{mode: :timestamp, metadata: %{status: "draft"}}) + end + + test "returns true for slug mode" do + assert Shared.should_regenerate_cache?(%{ + mode: :slug, + metadata: %{status: "draft"}, + version: 1 + }) + end + + test "returns true for published posts" do + assert Shared.should_regenerate_cache?(%{ + mode: :slug, + metadata: %{status: "published"}, + version: 1 + }) + end + + test "returns true when version is nil" do + assert Shared.should_regenerate_cache?(%{mode: :slug, metadata: %{status: "draft"}}) + end + + test "returns false for unknown mode with non-published status" do + refute Shared.should_regenerate_cache?(%{ + mode: :other, + metadata: %{status: "archived"}, + version: 1 + }) + end + + test "handles missing metadata gracefully" do + assert Shared.should_regenerate_cache?(%{mode: :timestamp}) + end + + test "handles empty map" do + # No mode → nil version → true (always regenerate when unknown) + assert Shared.should_regenerate_cache?(%{}) + end + end + + # ============================================================================ + # audit_metadata/2 + # ============================================================================ + + describe "audit_metadata/2" do + test "returns empty map for nil scope" do + assert Shared.audit_metadata(nil, :create) == %{} + assert Shared.audit_metadata(nil, :update) == %{} + end + end + + # ============================================================================ + # resolve_db_version/2 + # ============================================================================ + + describe "resolve_db_version/2" do + test "function exists and is callable" do + # Just verify the function is defined (actual DB calls tested in integration) + assert function_exported?(Shared, :resolve_db_version, 2) + end + end +end