Humblee

A humble PHP framework & CMS

Frontend

The Humblee admin interface is a hybrid architecture: the server renders HTML pages, and individual admin tools are modern JavaScript single-page applications (SPAs) built with Svelte and TypeScript, embedded in those pages. The public-facing side of the site uses whatever HTML and CSS your view templates produce — no frontend framework is prescribed.


The Admin SPAs

Each tool in the admin panel that requires rich interactivity is a dedicated Svelte application. The current tools are:

App Path Purpose
admin-home admin/ Dashboard — activity overview
blocks admin/blocks Content block type management
media-manager admin/media File upload, organization, and access control
page-editor admin/pages/{id} Inline content editing with draft/publish workflow
page-manager admin/pages Page list, reordering, slug management
personalization admin/personalization Audience segment rules for content variants
session-monitor (admin layout) Watches session expiry and warns the user
templates admin/templates Template and block slot configuration
toolbar (public pages) Inline editing toolbar shown to authenticated editors
tools admin/tools Developer utilities
user-manager admin/users User account and role management

Each app is fully isolated — it does not share code with other apps at runtime.


Build Pipeline

Directory layout

/package.json             ← Root workspace: exposes npm run build/dev commands
/public/package.json      ← Bulma CSS and CSS utilities (web-accessible, never bundled)
/frontend/package.json    ← Vite, Svelte, TypeScript (build-time only)
  └── apps/               ← npm workspace: one project per admin tool
      ├── media-manager/
      ├── page-editor/
      └── ...

The two npm contexts (public/ and frontend/) are intentionally separate. Bulma must remain in public/node_modules/ where the PHP templates can reference it by path. Hoisting it into a shared node_modules/ would break those references.

Commands (run from the project root)

npm run setup                  # Install all deps — run once after cloning
npm run build                  # Rebuild all admin apps
npm run build:media-manager    # Rebuild one app
npm run dev:media-manager      # Vite dev server with hot reload for one app
npm run dev:page-editor        # Vite dev server for the page editor

Replace the app name after build: or dev: with any directory name under frontend/apps/.

Build output

Each app compiles to a fixed-name pair of files:

public/humblee/js/admin/{app-name}/
├── index.js     ← Compiled Svelte + TypeScript (ES module)
└── index.css    ← Scoped component styles

These files are committed to git. There are no content-hash suffixes — filenames are always index.js and index.css. After making any change to Svelte source files, rebuild the affected app and commit both the source change and the new index.js/index.css.


How PHP Loads a Svelte App

The PHP admin controller sets the $this->extra_head_code property in the relevant action method. The admin layout template outputs this inside <head>, so the module loads before the page body becomes interactive.

// In humblee/src/Controller/Admin.php:
public function media(): void
{
    $this->page_title       = 'Media';
    $this->extra_head_code  = '<link rel="stylesheet" href="' . _app_path . 'humblee/js/admin/media-manager/index.css">';
    $this->extra_head_code .= '<script type="module" src="' . _app_path . 'humblee/js/admin/media-manager/index.js"></script>';
}

The corresponding view template also includes a <div id="app"></div> mount point and a <script> block that sets the configuration global (see below).


PHP → Svelte Configuration Injection

PHP passes runtime values to the Svelte app through a global object set before the module loads. There is no separate configuration file or environment variable — all context comes from PHP at page render time.

PHP side (in the view template)

<script>
window.__MEDIA_CONFIG__ = {
    XHR_PATH:       "<?php echo _app_path ?>core-request/",
    WEB_ROOT:       "<?php echo _app_path ?>",
    hasAdminRole:   <?php echo json_encode((bool) Core::auth(['admin', 'developer'])) ?>,
    hasMediaRole:   <?php echo json_encode((bool) Core::auth('media')) ?>,
    csrf:           <?php echo json_encode(\Humblee\Model\Crypto::get_hmac_pair()) ?>
};
</script>
<div id="app"></div>

Rules for config injection:

  • Use json_encode() for all values — it handles escaping and PHP→JS type coercion
  • Cast PHP booleans explicitly: (bool) $value
  • Always include an initial HMAC pair (csrf) so the first POST is immediately ready
  • Never include PHP objects, circular references, or anything that cannot safely serialize to JSON

Svelte side (App.svelte)

<script lang="ts">
  import MediaManager from './lib/MediaManager.svelte'

  const config = (window as any).__MEDIA_CONFIG__

  const xhrPath: string    = config.XHR_PATH
  const hasAdminRole: boolean = config.hasAdminRole
  let csrf = config.csrf   // mutable — refresh after each successful POST
</script>

<MediaManager {xhrPath} {hasAdminRole} bind:csrf />

Read window.__* only in App.svelte. Pass values as typed props to child components — never let child components reach into window directly.

Config global naming convention

App Global
media-manager window.__MEDIA_CONFIG__
page-editor window.__PAGE_EDITOR_CONFIG__
templates window.__TEMPLATES_CONFIG__
page-manager window.__PAGE_MANAGER_CONFIG__
admin-home window.__ADMIN_HOME_CONFIG__

Pattern: window.__[APP_NAME_UPPER_SNAKE]_CONFIG__


HMAC Refresh Pattern

Every POST to an XHR endpoint requires a valid HMAC pair. After each successful response, the server returns a fresh pair. The Svelte app must update its stored csrf before the next request.

Service layer (TypeScript)

// src/lib/services/myApi.ts
export async function saveItem(
    xhrPath: string,
    csrf: { key: string; token: string },
    formData: FormData
) {
    formData.append('hmac_key',   csrf.key)
    formData.append('hmac_token', csrf.token)

    const res  = await fetch(xhrPath + 'my_group/save', { method: 'POST', body: formData })
    const data = await res.json()

    return data  // caller updates csrf from data.csrf
}

Component (Svelte)

<script lang="ts">
  export let csrf: { key: string; token: string }

  async function handleSave() {
    const fd = new FormData()
    fd.append('name', itemName)

    const data = await saveItem(xhrPath, csrf, fd)
    if (data.csrf) csrf = data.csrf  // refresh for next call
  }
</script>

The bind:csrf in App.svelte propagates the update up so all child components share the refreshed token.


Adding a New Svelte App

See Extending Humblee for the full step-by-step walkthrough. The short version:

  1. Copy an existing app directory under frontend/apps/ and rename it
  2. Update vite.config.ts with the new app name in outDir
  3. Add a build:new-app and dev:new-app script to the root package.json
  4. Build once (npm run build:new-app) to create the public/humblee/js/admin/new-app/ output
  5. Wire the admin controller action to load the assets via $this->extra_head_code
  6. Add the PHP view template with the config injection block and <div id="app"></div>