Documentation

Custom Modules

How to add first-party DesertCMS modules with admin settings, public routes, static artifacts, tests, and deployment hooks.

Source: CUSTOM_MODULES.md

DesertCMS modules are first-party features that can be enabled from the admin UI and rebuilt into the public site. Existing examples are Map, Shop, Gallery, Forms, and Documentation.

Use a module when the feature has its own public path, admin settings, generated assets, or database-backed behavior. Use normal page content when the work is only editorial content.

Module Shape

A typical module touches these areas:

  • lib/DesertCMS/Modules.pm defines the module card and enable rule.
  • lib/DesertCMS/Settings.pm provides defaults and whitelists saved settings.
  • lib/DesertCMS/App.pm exposes admin settings routes and dynamic public endpoints when needed.
  • lib/DesertCMS/Renderer.pm writes static public pages, navigation links, sitemap entries, and cleanup behavior.
  • lib/DesertCMS/DB.pm owns schema changes when the module needs tables.
  • themes/default/assets/site.css carries public presentation.
  • t/17_modules.t verifies enable, disable, navigation, generated files, and sitemap behavior.

Register The Module

Add a definition in DesertCMS::Modules::definitions:

{
    key           => 'docs',
    label         => 'Documentation',
    description   => 'Adds a public /docs/ markdown knowledge base.',
    public_path   => '/docs/',
    settings_path => '/admin/settings/modules/docs',
    enabled       => enabled($settings, 'docs'),
}

Then add the enable rule in enabled:

if (($key || '') eq 'docs') {
    return _truthy(_setting($settings, 'module_docs_enabled', 0));
}

Default to disabled unless the module is core to every DesertCMS site. Map defaults on; optional modules such as Gallery, Forms, and Documentation default off.

Add Settings

Add defaults in DesertCMS::Settings::all:

module_example_enabled => _config_truthy($config, 'module_example_enabled', 0),
example_title          => 'Example',
example_intro          => 'A short public description.',

Add the same keys to the allowed list in set_many. Settings not listed there are ignored on save.

For settings that can come from /etc/desertcms.conf, use config defaults first, then allow admin settings to override them through the database.

Add Admin Routes

Module settings routes live under /admin/settings/modules/<module>.

if ($path eq '/admin/settings/modules/example') {
    return $self->_require_login($request, sub {
        $self->_module_example_settings_page($request, @_);
    });
}
if ($path eq '/admin/settings/modules/example/save') {
    return $self->_require_post($request, sub {
        $self->_module_example_settings_save($request, @_);
    });
}

The save handler should:

  • Verify CSRF through _require_post.
  • Normalize user input before saving.
  • Call DesertCMS::Settings::set_many.
  • Rebuild public output when the setting changes public behavior.
  • Return the same settings page with a short status message.

Keep the form simple. The admin UI is for operators, not developers.

Publish Public Artifacts

Hook the module into DesertCMS::Renderer::rebuild_indexes:

if (DesertCMS::Modules::enabled($site, 'example')) {
    _write_example_page($config, $db);
} else {
    _remove_example_artifacts($config);
}

A good public module writes predictable static files:

  • /example/index.html
  • optional data under /assets/example.json
  • related CSS through the theme asset pipeline
  • sitemap entries
  • navigation links

Cleanup matters. If a module is disabled, remove generated module files so stale public pages do not remain visible.

Dynamic Endpoints

Prefer static public pages. Add dynamic endpoints only when the browser must post or fetch live data, such as comments, ratings, analytics, forms, shop checkout, or webhooks.

Dynamic public routes should:

  • Use the existing CGI app and main site config.
  • Validate request method.
  • Rate limit or token-check public writes.
  • Avoid exposing private originals, config secrets, or admin-only data.
  • Return compact JSON or a redirect, not a large app shell.

Database Changes

Database migrations belong in DesertCMS::DB. Keep schema ownership explicit and make migrations idempotent.

For a module table:

CREATE TABLE IF NOT EXISTS example_items (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    title TEXT NOT NULL,
    created_at INTEGER NOT NULL
)

Do not make module data depend on public generated files as the source of truth. Public files are rebuild artifacts.

Security Checklist

  • Public writes need CSRF, signed tokens, rate limits, or IP/user-agent hashes depending on the workflow.
  • Admin saves need _require_post and existing session checks.
  • Uploaded originals stay outside the public root.
  • Generated public files should contain escaped user content.
  • Webhook handlers must verify provider signatures before mutating state.
  • Module settings should be whitelisted in DesertCMS::Settings.
  • Disable cleanup should remove stale public artifacts.

Test Checklist

At minimum, update t/17_modules.t to prove:

  • The module defaults to the intended enabled state.
  • Enabling it adds navigation and generated public files.
  • The generated HTML contains expected content.
  • The sitemap includes module URLs.
  • Disabling it removes navigation, generated files, and sitemap URLs.

Run:

prove -l t/01_syntax.t t/17_modules.t

On OpenBSD staging, run the same tests before activation.