Theme Development

Create custom themes for Scriptlog with ThemeRenderer, template helpers, FrontService, and comprehensive asset management.

Home / Documentation / Theme Development

Theme Directory Structure

Themes live in public/themes/. Each theme is a directory with templates, helper modules, shared partials, translations, and assets. The blog theme is the reference implementation. Bundled themes: blog, tastybites, restoblog, valdur (HTMX-powered, see docs/dev-docs/HTMX_THEME_DEVELOPER_GUIDE.md).

TEXT directory structure
public/themes/your-theme/
├── theme.ini              # Theme metadata (REQUIRED)
├── functions.php          # Thin loader for the modules below (REQUIRED)
├── functions-i18n.php     # t(), locale_url(), language_switcher(), ...
├── functions-nav.php      # front_navigation(), build_menu_tree(), ...
├── functions-post.php     # post retrieval, prepare_post_card(), theme_*_url(), ...
├── functions-media.php    # get_slideshow(), display_galleries(), thumbnails
├── functions-comments.php # total_comment(), block_csrf(), comments renderer
├── header.php             # HTML head, navigation, CSS assets
├── footer.php             # Scripts, footer content, cookie consent
├── home.php               # Homepage template
├── single.php             # Single post view (incl. password-protected posts)
├── page.php               # Static page template
├── category.php           # Category archive
├── tag.php                # Tag archive
├── archive.php            # Monthly archive
├── archives.php           # Archive index
├── blog.php               # Blog listing
├── search.php             # Search results page
├── sidebar.php            # Sidebar widgets
├── 404.php                # Error page (rendered by ThemeRenderer::render404())
├── privacy.php            # Privacy policy
├── cookie-consent.php     # GDPR banner (included by footer.php)
├── download.php           # Download info page
├── download_file.php      # File stream handler (no header/footer wrapper)
├── render-comments.php    # Legacy comments wrapper (markup in partials/)
├── index.php              # Kept for compatibility (NOT used for routing)
├── partials/              # Shared reusable markup
│   ├── card.php           # Post card (home + all listing templates)
│   ├── meta.php           # Author / date row for cards
│   ├── paginator.php      # Shared pagination wrapper
│   └── comments.php       # Comment list + load-more section
├── lang/                  # Translations (en always required, fallback language)
│   ├── en.json
│   ├── ar.json
│   ├── zh.json
│   ├── fr.json
│   ├── ru.json
│   ├── es.json
│   └── id.json
└── assets/                # CSS, JS, vendor, images
    ├── css/               # Source + .min.css production versions
    ├── js/                # Source + .min.js production versions
    ├── vendor/            # bootstrap, jquery, font-awesome, fancybox, popper.js, ...
    └── img/               # hero.jpg, placeholder.svg, favicon.ico, ...

Theme Configuration (theme.ini)

Every theme requires a theme.ini file with metadata. theme_directory must match the folder name exactly (ThemeDao::findThemeByDirectory() verifies identity):

INI theme.ini
[info]
theme_name = "My Custom Theme"
theme_designer = "Your Name"
theme_description = "Description of your theme"
theme_directory = "my-theme"

Reference: the bundled blog theme uses theme_name = Bootstrap Blog, theme_designer = Ondrej Svetska, theme_directory = blog. Activation state is stored in tbl_themes.theme_status ('Y' active, 'N' inactive), not in settings.

Template Loading Pattern (Critical)

NEVER call call_theme_header() or call_theme_footer() inside a template. The core system (Dispatcher → ThemeRenderer::render($routeKey)) loads header.php, then {routeKey}.php, then footer.php automatically.

PHP correct template format
<?php
defined('SCRIPTLOG') || die('Direct access not permitted');

// NO call_theme_header() here - the system handles it
// Template content starts directly:
?>
<div class="container">
    <!-- Page content here -->
</div>
<?php
// NO call_theme_footer() here - the system handles it

Template Hierarchy

Routes are defined in lib/core/Bootstrap.php :: defineRoutingRules(). The Dispatcher validates content existence before rendering (via FrontService through the front_service() helper) and renders the template whose filename matches the route key:

Route Key URL Pattern Template
home/home.php
single/post/{id}/{slug}single.php
page/page/{slug}page.php
category/category/{slug}category.php
tag/tag/{tag}tag.php
archive/archive/{mm}/{yyyy}archive.php
archives/archivesarchives.php
blog/blog*blog.php
search/search (permalinks ON) or ?q= on app root (OFF)search.php via SearchController
privacy/privacyprivacy.php
locale/locale— handled by LocaleController (language switch)
download/download/{uuid}download.php
download_file/download/{uuid}/filedownload_file.php (no wrapper)
404any unmatched URL / missing content404.php via ThemeRenderer::render404()

Canonical URLs are enforced: a wrong slug (e.g. /post/2/wrong-slug) returns 404. Never call http_response_code() or exit() in templates — the Dispatcher owns status codes.

Core Template Files

header.php / footer.php

Loaded automatically around every page by ThemeRenderer. The header provides the HTML head, nav, non-blocking CSS, RTL stylesheet (only when is_rtl()), and the scriptlog_vars JS object (api_url, site_url, theme_dir, search strings). The footer loads JS in dependency order (jQuery synchronously first, everything else defer) and includes cookie-consent.php at the very end.

functions.php + functions-*.php

functions.php is a thin loader (REQUIRED): it loads theme_escape_html(), the shared ViewModel layer (ThemeHelper::loadShared()), then the five helper modules functions-i18n.php, functions-nav.php, functions-post.php, functions-media.php, functions-comments.php. Every helper is guarded with function_exists(). Data helpers call FrontService / models / DAOs — templates contain display logic only, no SQL.

single.php (incl. password-protected posts)

Public posts render content resolved by ProtectedPostService::resolve() (already sanitized — printed as-is). Protected posts show an inline unlock form (AJAX POST /api/v1/posts/{id}/unlock, max 5 attempts / 15 min per IP, AES-256-CBC content with bcrypt password hash). If the post is missing the template shows a plain message — the Dispatcher already handled the 404.

Listing templates + partials/

home.php, category.php, tag.php, archive.php, and blog.php all render post cards through the shared partials/card.php (data normalized once by prepare_post_card() into an escaped PostViewModel) and share partials/paginator.php plus sidebar.php (populated by prepare_sidebar() → SidebarViewModel). render-comments.php is a legacy wrapper — the real markup lives in partials/comments.php.

search.php / sidebar search

Full-page search is fed by SearchController through $GLOBALS['search_results'], $GLOBALS['search_keyword'], $GLOBALS['search_pagination'], and $GLOBALS['search_rate_limited'] (FULLTEXT engine: SearchFinder). The sidebar widget autocompletes via GET /api/v1/search (assets/js/search.js, 300 ms debounce). Never hard-code /search — build the form action with theme_search_url() so query-string mode (?q=) keeps working.

Template Functions

i18n Functions (functions-i18n.php)

PHP i18n functions
// Translate a key; %param% placeholders are replaced from $params
t('sidebar.latest_posts.title');
t('search.found_results', ['count' => 3, 'keyword' => 'php']);

// Locale helpers
get_locale();            // e.g. 'en', 'ar', 'id'
available_locales();     // ['en', 'ar', 'zh', 'fr', 'ru', 'es', 'id']
is_rtl();                // true for Arabic
get_html_dir();          // 'ltr' or 'rtl'
locale_url('/path');     // locale-prefixed URL (permalink-aware)
get_language_name($locale, true);
language_switcher($args);

Missing keys fall back to en, then to the key itself. Keys use dot notation (nav.dashboard), never camelCase. Server-side interpolation is %param%; {{placeholder}} is JS-side only.

Post Retrieval & Preparation (functions-post.php)

PHP post functions
// Retrieval (prefer front_service() for new code)
featured_post();              // random headline post(s)
sticky_page();                // random sticky page
random_posts(0, 6);
latest_posts($limit, $position);
retrieve_blog_posts();
retrieve_detail_post($id);
posts_by_category($topicId);  // ['entries' => ..., 'pagination' => ...]
posts_by_tag($tag);
searching_by_tag($tag);
posts_by_archive($values);
archive_index();              // months + counts, grouped by year
retrieve_page($arg, $rewrite);

// Preparation: normalize once into escaped ViewModels
prepare_post_card($entry);    // PostViewModel for partials/card.php
prepare_page($entry);         // PageViewModel
prepare_archive($entry);      // ArchiveViewModel
prepare_sidebar();            // SidebarViewModel (latest, cats, archives, tags)

// Permalink-aware URL builders (never hard-code routes)
theme_post_url(['ID' => 5, 'post_slug' => 'slug']);
theme_page_url(['ID' => 2, 'post_slug' => 'about']);
theme_topic_url($row);
theme_tag_url($tag);
theme_archive_url($month, $year);
theme_search_url();           // {base}/search (permalinks ON) or {base}/ (OFF)

Navigation, Comments & Media

PHP nav / comments / media / core helpers
// Navigation (functions-nav.php; theme_navigation() lives in lib/utility/)
theme_navigation('public');   // raw ['items' => ..., 'parents' => ...]
front_navigation(0, $menu);   // builds MenuViewModel tree, renders HTML
build_menu_tree($items, $parents);
convert_menu_link($link, $permalinkEnabled);

// Comments (functions-comments.php) — state-changing forms need block_csrf()
total_comment($postId)['total'];   // returns ['total' => int], not an int
block_csrf();                      // hidden CSRF input
render_comments_section($postId, 0);

// Media (functions-media.php)
get_slideshow($limit = 5);
display_galleries($start, $limit);
get_post_thumbnail($post_img, $post_title, $img_alt = '');
get_download_page_data($identifier);   // UUID download lookup

// Shared core helpers (loaded by core, used in every template)
theme_escape_html($string);   // THE output-escaping boundary
front_service();              // shared FrontService (replaces FrontHelper)
theme_dir();                  // current theme directory URL
app_url(); app_sitename(); app_tagline();
make_date($timestamp);        // display only — never in form <input> values

Image Display System

Responsive images with WebP support and automatic fallback. Files are stored under public/files/pictures/ (originals + .webp at root, sized variants in small/, medium/, large/). Always build paths with the APP_IMAGE* constants from lib/common.php — never hard-code /public/files/pictures/.

Image Functions

PHP image functions
// Simple featured image (thumbnail)
invoke_frontimg($media_filename, $image_thumb = true);

// Responsive <picture> with WebP fallback
invoke_responsive_image(
    $media_filename,
    $size = 'thumbnail',   // 'thumbnail' | 'medium' | 'large'
    $image_thumb = true,
    $alt = '',
    $class = 'img-fluid',
    $fetchpriority = false,
    $decoding = 'auto',
    $loading = 'auto'      // 'lazy' | 'eager' | 'auto' (no attribute when 'auto')
);

// Hero / LCP image (fetchpriority="high")
invoke_hero_image($media_filename, $fallback_url = '', $alt = '');

// Gallery thumbnail (lazy-loaded, for Fancybox links)
invoke_gallery_image($media_filename, $alt = '');

Image Sizes

Size Dimensions Folder Prefix
thumbnail 640 x 450 small/ small_
medium 730 x 486 medium/ medium_
large 1200 x 630 large/ large_

Asset Management

Themes use Bootstrap 4 + jQuery with vendor libraries (the Bootstrap 5 stylesheet on this documentation site belongs to the docs UI, not to themes). CSS loads non-blocking (media="print" onload="this.media='all'" + <noscript> fallback); post-jQuery scripts use defer in dependency order; production markup adds SRI integrity/crossorigin and a CSP nonce on inline scripts.

Core Theme Assets (blog theme)

Asset Location
Main CSS assets/css/style.sea.min.css (source: style.sea.css)
Custom / comment / privacy / cookie / RTL / nav CSS assets/css/custom.min.css, comment.min.css, privacy.min.css, cookie-consent.min.css, rtl.min.css (RTL only), sina-nav.min.css, prism-override.min.css
Theme JS assets/js/front.min.js, search.min.js, unlock-post.min.js, comment-submission.min.js, load-comment.min.js, cookie-consent.min.js, rtl.min.js (RTL only)
Bootstrap 4 assets/vendor/bootstrap/css/bootstrap.min.css, assets/vendor/bootstrap/js/bootstrap.min.js
jQuery + plugins assets/vendor/jquery/, popper.js/ (before Bootstrap JS), jquery.cookie/, @fancyapps/fancybox/, prism/, font-awesome/

Load order matters: jQuery synchronously first, then Popper.js, Bootstrap JS, cookie/Fancybox/Prism plugins, then theme scripts. Popper.js must load before Bootstrap JS or dropdowns (including the language switcher) break. header.php exposes scriptlog_vars (API/site/theme URLs + search strings) for search.js and friends.

Asset Minification

Use the minification script before deployment (it scans every theme's assets/css/ and assets/js/, skipping existing .min.* files):

BASH command
php tmp/minify.php

This generates minified .min.css and .min.js files from source files. Load sources (front.js) while developing, minified files (front.min.js) in production.

Security

Every template starts with the access guard, escapes dynamic output exactly once at theme_escape_html(), and keeps queries and sanitization out of templates:

PHP escaping boundary
<?php defined('SCRIPTLOG') || die('Direct access not permitted'); ?>

<!-- Plain text: escape once -->
<?= theme_escape_html($post['post_title']); ?>
<a href="<?= theme_escape_html($postUrl); ?>"><?= theme_escape_html($post['post_title']); ?></a>

<!-- Already-sanitized HTML: print as-is, never escape twice -->
<?= $post_content; ?>   <!-- sanitized by htmLawed() in the service layer -->
<?= paragraph_l2br(safe_html(paragraph_trim($post['post_content']))); ?>

<!-- State-changing forms carry a CSRF token (search uses GET + honeypot instead) -->
<form method="post">
    <?= block_csrf(); ?>
</form>
Guard every PHP file with defined('SCRIPTLOG') || die()
Escape all dynamic output with theme_escape_html(); never chain htmlout() + safe_html() (double-encodes entities like &quot;)
No SQL in templates — use functions-*.php helpers / front_service(); no http_response_code() / exit() in templates
Protected posts resolved by ProtectedPostService only; never expose the passphrase to the frontend
Inline <script> blocks carry the CSP nonce; never expose absolute server paths

Internationalization (i18n)

The frontend locale ($_SESSION['scriptlog_locale'], cookie scriptlog_locale, ?switch-lang= handled in lib/main.php) is independent of the admin locale. Dictionaries live in lang/*.json; a missing key falls back to en, then to the key itself — so non-English dictionaries may be partial.

Code Language Direction
enEnglish (fallback)LTR
arالعربيةRTL
zh中文LTR
frFrançaisLTR
ruРусскийLTR
esEspañolLTR
idBahasa IndonesiaLTR

Navigation & URL Compatibility

Themes must work with permalinks both ON (SEO-friendly: /post/1/slug) and OFF (query-string: ?p=1, ?pg=1, ?cat=1, ?a=032025). convert_menu_link() converts stored menu links dynamically, and locale_url() only prefixes non-default locales when permalinks and the prefix toggle are both enabled. The header language switcher uses ?switch-lang=XX&redirect=... with permalinks OFF and locale_url() with them ON.

RTL Support

RTL (Arabic) is detected with is_rtl() / get_html_dir(). Load the RTL assets conditionally and set the document direction from the locale:

PHP RTL assets
<html lang="<?= get_locale(); ?>" dir="<?= get_html_dir(); ?>">

<?php if (is_rtl()): ?>
    <link rel="stylesheet" href="<?= theme_dir(); ?>assets/css/rtl.min.css">
<?php endif; ?>
<?php if (is_rtl()): ?>
    <script src="<?= theme_dir(); ?>assets/js/rtl.min.js" defer></script>
<?php endif; ?>

Creating Custom Themes

Follow these steps to create your own custom theme. Copy public/themes/blog/ as a starting point — it has the correct loader, partials, escaping, and vendor wiring. The full 10-step workflow is in THEME_DEVELOPER_GUIDE.md §13.

Directory Structure

Create public/themes/your-theme/ with templates, the five functions-*.php modules, partials/, lang/en.json, and assets/.

Required for every theme
Theme Configuration

Add theme.ini with name, designer, description, and a theme_directory matching the folder name exactly.

Required metadata file
Core Templates

Build header.php, footer.php, and content templates (home, single, page, archives, search). Never load header/footer from templates.

Content templates only
Theme Functions

Add functions.php as a thin loader for your functions-*.php modules (with function_exists() guards). Prefer front_service() for data access.

Required loader + modules
Assets

Place CSS, JS, and images in assets/, copy needed vendor/ libraries from the blog theme, then run php tmp/minify.php.

Optimize with minify.php
Activate

Register the theme, then activate it via Appearance → Templates (admin/index.php?load=templates) in the admin panel.

Admin panel required

Best Practices

Follow these guidelines for maintainable and professional themes:

Template Structure

Never call call_theme_header() / call_theme_footer() — ThemeRenderer wraps every template with header → content → footer automatically.

i18n Ready

Use t('section.key') with dot-notation keys for every user-facing string; keep lang/en.json complete (fallback language).

Escape Once

Escape all dynamic output with theme_escape_html() — the single boundary. Print service-sanitized HTML as-is; never double-escape.

Responsive Images

Use invoke_responsive_image() (WebP + fallback), invoke_hero_image() for LCP, and invoke_gallery_image() for lightbox galleries.

Clean Templates

Keep templates display-only: data via functions-*.php / front_service(), repeated markup in partials/ (card, meta, paginator, comments).

Performance

Non-blocking CSS, defer scripts in order, lazy-loaded images, and minified .min.css / .min.js via php tmp/minify.php.

RTL Support

Test LTR and RTL layouts. Use is_rtl() / get_html_dir() and load rtl.min.css / rtl.min.js conditionally.

Quality Gates

Run php -l on every template, verify the §15.3 functional checklist (search, unlock, comments, consent, downloads), and keep the console error-free.

Theme Development Checklist

Create theme.ini with complete metadata (theme_directory matches folder)
Implement required templates (header, footer, home, single, page, archives, search, 404, privacy, download) with the SCRIPTLOG guard and no header/footer calls
Add functions.php thin loader + functions-*.php modules and shared partials/
Escape output with theme_escape_html(); CSRF via block_csrf() on POST forms (search_verify honeypot on search GET)
Use invoke_responsive_image() for featured images; build search URLs with theme_search_url()
Test all 7 locales plus LTR and RTL rendering
Minify all CSS and JavaScript with php tmp/minify.php