Theme Development
Create custom themes for Scriptlog with ThemeRenderer, template helpers, FrontService, and comprehensive asset management.
Current as of August 2026
Templates are rendered by the core ThemeRenderer (header → content → footer) — templates never load the header/footer themselves. Output escaping has one boundary: theme_escape_html(). functions.php is a thin loader for the five functions-*.php modules, and frontend data comes from Scriptlog\Service\FrontService via front_service() (FrontHelper is deprecated). Full reference: docs/dev-docs/THEME_DEVELOPER_GUIDE.md.
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).
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, ...
No index.php fallback: every route maps directly to {routeKey}.php (see Template Hierarchy below). If the active theme is missing or broken, the system falls back to the blog theme.
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):
[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
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
Calling the header/footer from a template causes duplicated navigation and scripts, and include 'header.php' / 'footer.php' from templates breaks 404 status codes ("headers already sent" — the Dispatcher sets the status before rendering). Only download_file.php bypasses the header/footer wrapper (it streams a file).
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 | /archives | archives.php |
blog | /blog* | blog.php |
search | /search (permalinks ON) or ?q= on app root (OFF) | search.php via SearchController |
privacy | /privacy | privacy.php |
locale | /locale | — handled by LocaleController (language switch) |
download | /download/{uuid} | download.php |
download_file | /download/{uuid}/file | download_file.php (no wrapper) |
| 404 | any unmatched URL / missing content | 404.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)
// 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)
// 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
// 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
// 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):
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 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>
defined('SCRIPTLOG') || die()
theme_escape_html(); never chain htmlout() + safe_html() (double-encodes entities like ")
functions-*.php helpers / front_service(); no http_response_code() / exit() in templates
ProtectedPostService only; never expose the passphrase to the frontend
<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 |
|---|---|---|
en | English (fallback) | LTR |
ar | العربية | RTL |
zh | 中文 | LTR |
fr | Français | LTR |
ru | Русский | LTR |
es | Español | LTR |
id | Bahasa Indonesia | LTR |
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:
<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/.
Theme Configuration
Add theme.ini with name, designer, description, and a theme_directory matching the folder name exactly.
Core Templates
Build header.php, footer.php, and content templates (home, single, page, archives, search). Never load header/footer from templates.
Theme Functions
Add functions.php as a thin loader for your functions-*.php modules (with function_exists() guards). Prefer front_service() for data access.
Assets
Place CSS, JS, and images in assets/, copy needed vendor/ libraries from the blog theme, then run php tmp/minify.php.
Activate
Register the theme, then activate it via Appearance → Templates (admin/index.php?load=templates) in the admin panel.
Pro Tip for Beginners
Start by copying the default public/themes/blog/ theme and renaming it. Then modify one element at a time to understand how each part works. This is the safest way to learn theme development!
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
theme.ini with complete metadata (theme_directory matches folder)
SCRIPTLOG guard and no header/footer calls
functions.php thin loader + functions-*.php modules and shared partials/
theme_escape_html(); CSRF via block_csrf() on POST forms (search_verify honeypot on search GET)
invoke_responsive_image() for featured images; build search URLs with theme_search_url()
php tmp/minify.php