Skip to content

Latest commit

 

History

History
335 lines (253 loc) · 9.82 KB

File metadata and controls

335 lines (253 loc) · 9.82 KB

DLTN Framework — Extending

Prefer content/extensions/{slug}/ for features. Edit core/ only for framework changes.

Extension layout (WordPress-style)

Each extension is one folder that contains everything it needs:

content/extensions/notes/
  notes.php              # bootstrap + headers (required)
  database/
    migrations/
      mysql/             # MySQL/MariaDB DDL
      sqlite/            # SQLite DDL (same basenames)
  templates/
    admin/               # admin shell pages
      index.hbs
    frontend/            # public-facing pages
    emails/              # optional: emails/{name}.hbs for Mailer
  src/                   # optional PHP classes (PSR-4: Dltn\Ext\Notes\)
  assets/                # optional static files → /ext/notes/...
    css/
    js/

Headers

The main file must declare headers (same idea as WordPress plugins). Prefer {slug}/{slug}.php. Plugin Name is accepted as an alias for Extension Name.

<?php

/**
 * Extension Name: Notes
 * Description: Simple notes admin page.
 * Version: 1.0.0
 * Author: You
 * Author URI: https://example.com
 */

declare(strict_types=1);

// register hooks below…

Without a valid Extension Name / Plugin Name, the folder is ignored.

Enable / disable

Admin → Extensions (/admin/extensions, requires manage_extensions). Disabled extensions stay on disk but are not booted (no templates, autoload, or bootstrap). State is an opt-out list in Settings (extensions.disabled). Enabling runs that extension's pending migrations. Schema is not rolled back on disable.

Force enable/disable in code (must register before extensions load):

add_filter('extension_enabled', function (bool $enabled, string $slug): bool {
    if ($slug === 'experimental') {
        return false;
    }
    return $enabled;
}, 10, 2);

Templates

If templates/ exists, it is registered automatically (when the extension is enabled). Prefer:

Folder Use for
templates/admin/ Authenticated admin pages
templates/frontend/ Public-facing pages

Template names are relative to templates/ (admin/indextemplates/admin/index.hbs). Global overrides in content/templates/ still win over extension templates.

Recipe: add an admin page

<?php

/**
 * Extension Name: Notes
 * Description: Simple notes admin page.
 * Version: 1.0.0
 * Author: You
 * Author URI: https://example.com
 */

declare(strict_types=1);

use Dltn\Core\Router;
use Dltn\Http\Controllers\Admin\AdminController;
use Dltn\Http\Request;
use Dltn\Http\Response;

final class NotesController extends AdminController
{
    public function index(Request $request): Response
    {
        return $this->adminPage('admin/index', [
            'heading' => 'Notes',
        ], '/admin/notes', 'Notes');
    }
}

add_filter('admin_menu', function (array $nav): array {
    $nav[] = [
        'link' => [
            'href'  => '/admin/notes',
            'label' => 'Notes',
            'cap'   => 'view_dashboard',
            'icon'  => 'sticky-note', // Lucide name — see Icons below
            'order' => 20, // after Dashboard (10); default is 50
        ],
    ];
    return $nav;
});

add_action('routes', function (Router $router): void {
    $router->get('/admin/notes', [NotesController::class, 'index'], ['auth', 'can:view_dashboard']);
});

Companion template: content/extensions/notes/templates/admin/index.hbs

<h1 class="text-xl font-semibold">{{heading}}</h1>

For larger extensions, put PHP classes in src/ under the PSR-4 namespace Dltn\Ext\{StudlySlug}\ (e.g. slug hello-worldDltn\Ext\HelloWorld\). The loader registers that autoload automatically — no manual require.

Icons (Lucide)

Admin UI icons use Lucide (the set shadcn/ui uses) — not Font Awesome.

Browse names: https://lucide.dev/icons

// In admin_menu — pass the Lucide name (resolved to SVG automatically):
'icon' => 'flask-conical',

// Or render SVG yourself:
icon('users', ['class' => 'size-4']);
\Dltn\Support\Icon::svg('settings');

Raw <svg>…</svg> markup is still accepted for admin_menu icons.

Recipe: public route

add_action('routes', function (\Dltn\Core\Router $router): void {
    $router->get('/hello', function ($request) {
        return \Dltn\Http\Response::html('<h1>Hello</h1>');
    });
});

Recipe: JSON API route

Core mounts /api and fires api_routes with a router already prefixed. Use auth.api + csrf + can:* for session-authenticated mutations (send X-CSRF-Token from GET /api/csrf). The same auth.api middleware also accepts Authorization: Bearer dltn_… (Super Admins mint tokens in System Settings → API tokens and pick which capabilities the token may use). CSRF is skipped when a Bearer header is present.

add_action('api_routes', function (\Dltn\Core\Router $router): void {
    $router->get('/notes', function ($request) {
        return \Dltn\Http\Response::json(['notes' => []]);
    }, ['auth.api', 'can:view_dashboard']);

    $router->post('/notes', function ($request) {
        $body = $request->json();
        if ($body === null) {
            return \Dltn\Http\Response::jsonError('invalid_json', 400);
        }

        return \Dltn\Http\Response::json(['ok' => true], 201);
    }, ['auth.api', 'csrf', 'can:view_dashboard']);
});

Paths above become /api/notes. Prefer [Controller::class, 'method'] for larger handlers under Dltn\Ext\{StudlySlug}\.

Bearer example:

curl -H "Authorization: Bearer dltn_…" https://example.test/api/me

Recipe: send mail

Configure SMTP via .env (SMTP_*config/mail.php), then:

$mailer = $container->get(\Dltn\Mail\Mailer::class);
$mailer->send('user@example.com', 'Name', 'Subject', 'invite', [
    'name' => 'Name',
    'org_name' => 'DLTN Framework',
    'accept_url' => 'https://example.org/...',
    'role' => 'Member',
]);

Templates resolve as emails/{name} via View: content/templates → extension templates/core/templates. Ship extension emails at content/extensions/{slug}/templates/emails/{name}.hbs. Filter payload with mail.before_send.

Recipe: extension assets

Put CSS/JS/images in content/extensions/{slug}/assets/. They are served at /ext/{slug}/... (PHP controller; never exposes .php).

// In a template or controller:
ext_asset('notes', 'css/app.css'); // → /ext/notes/css/app.css

Recipe: queue a background job

add_action('init', function (\Dltn\Core\Container $c): void {
    add_action('queue.send_digest', function (array $payload): void {
        // heavy work…
    }, 10, 1);
});

// Somewhere later:
$c->get(\Dltn\Queue\Queue::class)->push('send_digest', ['user_id' => 1]);

Workers: php bin/queue.php or the built-in drain inside php bin/cron.php.

Recipe: validation

$v = \Dltn\Support\Validator::make($request->all(), [
    'email' => 'required|email',
    'password' => 'required|min:10|confirmed',
]);
if ($v->fails()) {
    // $v->firstError() / $v->errors()
}

Recipe: extension schema (migrations)

Feature tables live inside the extension, not in core/database/migrations/. Ship both driver trees with the same basenames:

content/extensions/posts/
  posts.php
  database/
    migrations/
      mysql/
        0001_create_posts.sql
        0001_create_posts.php   # optional PHP backfill: return function (Database $db): void {}
      sqlite/
        0001_create_posts.sql
  src/
    PostRepository.php
  templates/
    admin/

Example MySQL (mysql/0001_create_posts.sql):

CREATE TABLE posts (
    id         BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    title      VARCHAR(255) NOT NULL,
    body       TEXT NULL,
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

Example SQLite (sqlite/0001_create_posts.sql):

CREATE TABLE posts (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    title      VARCHAR(255) NOT NULL,
    body       TEXT NULL,
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);

Assume an empty table prefix in migration DDL (or bake the prefix into the SQL if you use one). Prefer $db->insert() / $db->upsert() / $db->table() in PHP.

Core applies the tree for the active Database::driver() via MigrationRunner / Migrator. Versions are recorded as ext:{slug}/{basename} (e.g. ext:posts/0001_create_posts) so they never collide with core.

Run pending migrations:

php bin/migrate.php
php bin/migrate.php --pending   # list only

Install also runs core + all extension migrations found on disk.

In the extension, use core Database (and your own repository) — do not put Post models or SQL in core/:

add_action('init', function (\Dltn\Core\Container $container): void {
    // Repositories / services that call $container->get(Database::class)
});

Recipe: core-only migration

Framework tables only: add matching files under core/database/migrations/mysql/ and core/database/migrations/sqlite/ (same basename). Same runner; versions stay unprefixed (0002_my_framework_change).

Recipe: new capability

  1. Add the string to Dltn\Team\Permission::CAPABILITIES (Admins get it automatically unless you also add it to Permission::SUPER_ADMIN_ONLY). For Editor access, also add it to Permission::EDITOR_CAPABILITIES, or
  2. Filter admin_capabilities and enforce with custom middleware / checks in your controller.

Protect routes with middleware alias can:your_cap.

Recipe: alter every admin page

add_filter('admin_page_data', function (array $data): array {
    $data['extra_banner'] = 'Staging';
    return $data;
});

When to add a new named hook in core

Add a core hook when multiple extensions need the same seam and you would otherwise fork core. Document it in HOOKS.md.