Raiju/architecture.md
2026-10-25 06:05:42 +03:00

51 KiB
Raw Blame History

Raiju CMS - Architecture Reference

System Overview

Raiju is a flat-file CMS built on PHP with no database dependency. Content is stored as JSON files, pages are rendered via PHP templates, and the admin interface provides an inline editor for on-page editing plus a dashboard for page management.

Request Flow

  1. Request arrives at router.php (dev server) or .htaccess (Apache)
  2. Static files (CSS, JS, images) are served directly
  3. PHP templates: if {lang}/{path}.php exists, it is included directly
  4. JSON pages: if the matching content/pages/{lang}_{encoded-path}.json exists, the front controller includes/page.php renders it using renderAllSections(). / is encoded as __, so /products/vitamin-d maps to en_products__vitamin-d.json.
  5. News posts: URLs like /en/news/my-post route to {lang}/news-post.php
  6. 404: falls through to 404.php

Key Directories

admin/           Admin panel (login, dashboard, API, setup wizard)
content/
  pages/         Page JSON files ({lang}_{slug}.json, footer.json)
  forms/         Public form definitions
  news/          News post JSON files ({slug}.json)
  settings.json  Site-wide settings (favicon, theme, etc.)
  events.json    Events data
css/
  style.css      Base styles + CSS custom properties
  components.css Render component styles (pricing, FAQ, etc.)
  fonts.css      Custom font definitions (optional)
includes/
  header.php     HTML head + navigation + language switcher
  footer.php     Footer + scripts + inline editor bootstrap
  access-guard.php  Maintenance mode, preview bypass, and private page enforcement
  content-loader.php  Template API (editable fields, render components)
  forms.php            JSON-backed public forms
  ai/               AI provider gateway, limits, usage, audit, generated images
  ai/agent/         AI agent: tool gateway, safe fetch, capability map, change sets, loop, images, research
  block-types.php     Block type registry
  block-renderers/    One PHP file per block type
  nav-config.php      Navigation items + page mapping
  page.php            Front controller for JSON-only pages
  contact-form.php    Contact form partial
  sidebar.php         Sidebar partial
js/
  inline-editor.js    Inline editing system
  audio-player.js     Custom audio player
  faq-accordion.js    FAQ toggle behavior
router.php       Dev server router (replaces .htaccess)
examples/        Example templates and content files

Content File Format

Page JSON (content/pages/{lang}_{slug}.json)

Standard pages use a sections array:

{
  "page": "en_about",
  "lang": "en",
  "title": "About Us",
  "description": "Meta description for SEO.",
  "lastModified": "2026-03-23T12:00:00",
  "sections": [
    {
      "id": "section_1",
      "type": "heading",
      "text": "About Us",
      "level": "h1"
    },
    {
      "id": "section_2",
      "type": "text",
      "title": "Our Story",
      "content": "<p>HTML content here.</p>"
    }
  ]
}

Custom layout pages use arbitrary nested keys instead of sections:

{
  "page": "en_home",
  "lang": "en",
  "title": "Home",
  "hero": {
    "title": "Welcome",
    "subtitle": "Tagline here.",
    "cta": { "text": "Get Started", "href": "#features" },
    "image": { "src": "assets/images/hero.webp", "alt": "Hero" }
  },
  "features": {
    "heading": "Features",
    "items": {
      "0": { "icon": "zap", "title": "Fast", "desc": "No database." },
      "1": { "icon": "shield", "title": "Secure", "desc": "Minimal attack surface." }
    }
  }
}

List items are stored as numbered objects ("0": {...}, "1": {...}) for dot-notation compatibility with the inline editor.

Data-First Principle & Auto-Generation

The JSON file is the single source of truth. Best practice is to populate JSON with all content before referencing it in PHP templates.

However, missing fields are auto-generated: when an admin visits a page, any editableText, editableHtml, editableLink, or editableImage call whose key doesn't exist in JSON will automatically create the key using the PHP fallback value. This means an AI agent can write PHP templates freely and the JSON structure wires itself up on first admin visit. The auto-generated values appear in both the Visual Editor and the Content Editor immediately.

News Post JSON (content/news/{slug}.json)

{
  "slug": "my-post",
  "lang": "en",
  "title": "Post Title",
  "excerpt": "Short description.",
  "date": "2026-03-23",
  "author": "Author Name",
  "image": "assets/images/post-cover.webp",
  "hidden": false,
  "sections": [
    { "id": "s1", "type": "text", "title": "", "content": "<p>Post body.</p>" }
  ]
}
{
  "tagline": { "en": "Built with Raiju.", "de": "Erstellt mit Raiju." },
  "services": { "en": "Web Design & Development", "de": "Webdesign & Entwicklung" },
  "claim": { "en": "Your claim here.", "de": "Ihr Claim hier." },
  "contact": { "phone": "+1 234 567", "email": "info@example.com" },
  "contactHeading": { "en": "Contact", "de": "Kontakt" },
  "legalHeading": { "en": "Legal", "de": "Rechtliches" },
  "legalLinks": {
    "privacy": {
      "text": { "en": "Privacy Policy", "de": "Datenschutz" },
      "href": { "en": "privacy", "de": "de/datenschutz" }
    },
    "imprint": {
      "text": { "en": "Legal Notice", "de": "Impressum" },
      "href": { "en": "legal-notice", "de": "de/impressum" }
    }
  },
  "copyright": "&copy; [id=\"adminAccess\"]2026[/id] Your Company",
  "credit": { "text": "Built with", "link": "https://example.com", "linkText": "Raiju" }
}

The copyright field supports shortcode syntax: [id="foo"]content[/id] renders as <span id="foo">content</span>. The shortcode is the official way to wire up the hidden admin-access trigger — see Hidden Admin Access below.

The default footer markup tags the year inside the copyright line with id="adminAccess" via the shortcode shown above. includes/footer.php registers a dblclick handler on that element; double-clicking sends the user to /admin/?redirect=<current-path>. This gives admins a discreet entry point to the login page from any public page.

After successful login the behaviour is controlled by the general.frontendLoginRedirect site setting (configurable under Dashboard → Settings → Login):

Mode Behaviour
auto (default) The user is sent back to the page they came from, with the inline editor ready to use.
dashboard The user always lands in the dashboard. A one-shot info banner offers a link back to the source page.

The ?redirect=… parameter is sanitised by validateRedirectUrl() in admin/index.php — it must be same-origin and may not point inside /admin/, so the shortcode cannot be used for open redirects.

To move the trigger somewhere else (e.g. the tagline or a hidden element), simply place the [id="adminAccess"]…[/id] shortcode around any other footer text. Removing the shortcode disables the hidden access entirely; admins must then visit /admin/ directly.

Form JSON (content/forms/{id}.json)

Public forms can be stored as JSON definitions. This keeps the no-database model intact while giving admins a limited, safe editor for existing forms under Dashboard -> Settings -> Forms.

{
  "id": "contact",
  "label": "Kontaktformular",
  "description": "",
  "enabled": true,
  "submit": {
    "store": true,
    "email": true,
    "subject": "Kontaktanfrage: {name}",
    "successText": "Vielen Dank für deine Nachricht."
  },
  "fields": [
    { "key": "name", "type": "text", "label": "Name", "required": true, "width": 6 },
    { "key": "email", "type": "email", "label": "E-Mail", "required": true, "width": 6 },
    { "key": "phone", "type": "tel", "label": "Telefon", "required": false, "width": 12 },
    { "key": "message", "type": "textarea", "label": "Nachricht", "required": true, "width": 12 }
  ]
}

id is normalized to lowercase letters, numbers, dashes, and underscores and is used for content/forms/{id}.json, lazy rendering, CSRF-style form tokens, and mail metadata. Supported type values are text, email, tel, textarea, select, radio, checkbox, date, time, hidden, heading, and note. Supported width values are 3, 4, 6, 8, and 12 on a 12-column grid. select and radio fields use options[] entries with value and label.

submit.store=false skips local inbox storage. submit.email=false skips email notification even if SMTP/sendmail is configured. submit.subject supports simple placeholders such as {form}, {name}, and {email}.

Settings JSON (content/settings.json)

Generated by the setup wizard. Used by admin login, dashboard, header, footer, SEO helpers, and frontend access controls for branding, theme, login behaviour, maintenance mode, and privacy options.

{
  "branding": {
    "logo": "",
    "name": "My Site",
    "showBranding": true
  },
  "theme": {
    "adminTheme": "light",
    "primaryColor": "#2563eb",
    "accentColor": "#60a5fa"
  },
  "general": {
    "adminLanguage": "",
    "frontendLoginRedirect": "auto"
  },
  "access": {
    "maintenance": {
      "enabled": false,
      "mode": "maintenance",
      "title": "Maintenance",
      "text": "We will be back online shortly.",
      "until": "",
      "showCountdown": false,
      "bypassParam": "preview",
      "bypassKeyHash": ""
    },
    "privacy": {
      "obfuscateEmails": false
    }
  }
}

Optional top-level keys: favicon (path to favicon file, default: assets/images/favicon.svg), favicon_png (PNG fallback for SVG favicons, also used as apple-touch-icon).

The theme.adminTheme value can be "light", "dark", or "system". primaryColor and accentColor override --rj-primary and --rj-brand in the admin panel CSS.

general.frontendLoginRedirect controls the post-login redirect. "auto" returns the user to the page from which they triggered the login (via the footer double-click); "dashboard" always lands in the dashboard. See Hidden Admin Access above for details.

login controls admin login page presentation. brandAsset is none, favicon, or logo; image is an optional /assets/images/... path; imageLayout is none, background, left, or right; overlayColor and overlayOpacity define the background-image overlay; and boxStyle/boxColor/boxTextColor control whether the login form is shown in a card plus its background and text colors.

access.maintenance controls the public maintenance lock. mode is one of maintenance, offline, or launch; until is an optional local datetime string; showCountdown enables countdown rendering for visitors; brandAsset, image, imageLayout, overlayColor, and overlayOpacity mirror the login visual options; and bypassParam plus bypassKeyHash define a session-based preview bypass such as /?preview=secret. The plaintext bypass key is never stored by the settings API.

access.privacy.obfuscateEmails enables JavaScript-decoded email address placeholders on public pages. The original email remains available to visitors with JavaScript enabled but is not emitted as a simple raw address in the initial HTML.

AI Settings (content/ai-settings.json)

AI configuration is intentionally separate from general site settings because it contains provider credentials, budgets, model names, feature flags, and local provider controls. The gateway in includes/ai/ai-helper.php supports OpenAI-compatible providers, including OpenAI, OpenRouter, Ollama, LM Studio, or self-hosted compatible endpoints.

Provider credentials are stored per provider. This allows an installation to keep separate API keys and base URLs for OpenAI and OpenRouter while switching the active provider in the admin UI. load-ai-settings never returns raw API keys; it only exposes hasApiKey and non-secret provider metadata.

The keys (provider keys, research.brave.apiKey, stock.pexels.apiKey) are not kept in content/ai-settings.json but in content/ai-secrets.json (owner-only, in .gitignore), so the settings file can be committed. includes/ai/settings-store.php merges both for readers (raijuAiReadStoredSettings()) and splits them for writers (raijuAiWriteStoredSettings()); keys that an older version left in the settings file move on the next administrator load of the AI settings (raijuAiMigrateSecrets()). Never read or write the two files directly.

Image service. Images are generated by a service chosen apart from the chat provider (imageService.provider: inherit = the chat provider's own image models, openai-compatible, openrouter, kie, fal, comet, custom). includes/ai/image-services.json is the registry (services, adapters, base URLs, models with their parameters, verified date or false, optional price with source); includes/ai/image-services.php resolves the service, maps one input to each provider's parameters, runs the adapter and prices the request. raijuAiImageEffectiveSettings() puts the service's provider, base URL, key and model into the chat settings, so the OpenAI, OpenRouter and Kie paths in ai-helper.php run unchanged. Keys: imageServices.<id>.apiKey in content/ai-secrets.json, with the chat key of the same provider as fallback for OpenAI, OpenRouter and Kie. fal.ai tasks and Comet calls follow the same ledger rules as Kie (tasks with ids are resumed, uncertain submissions are never repeated). Prices: pricing.imageModels per service and model, the registry, then pricing.imageCentsPerRequest as the default; without any the price is not on file. A price may carry tables by resolution step and by quality, looked up with the resolution and quality of the request. The model lists of fal.ai and Comet are fetched by the server (ai-image-models) and cached for a day in content/ai-image-models-cache.json. The agent can pass reference images (a run's mediaRef or a library path) to generate_image where the service supports them. The agent's chat is also embedded in the "Assistant" card of the dashboard home tab (RaijuAiCopilot.embed()); the Kie.ai, fal.ai and Comet models (five each) and their parameters are listed in includes/ai/image-services.json.

Usage counters live in content/ai-usage.json; request audit lines live in content/ai-audit/YYYY-MM-DD.jsonl; generated image metadata lives in content/ai-image-history.json; generated image files are written to assets/images/generated/ and surfaced through the Media Library. Admin UI and future core features should use the AI gateway functions or admin/api.php AI actions so limits, audit logging, key handling, and generated-file storage stay centralized.

Models, reasoning effort and prices. chatModel and textModel (chat/agent and text/SEO purposes) and reasoning.{chat,text} (recommended, low, medium, high, or legacy for files written before the setting existed: nothing is sent) mirror the active provider; every provider's own choices are kept in providerModels[provider] and restored when it is selected. includes/ai/model-capabilities.json is the one registry for UI and backend: which model on which provider and host has a reasoning setting, its levels, the recommendation per purpose, whether temperature must be omitted and the thinking headroom. raijuAiReasoningPlan() and raijuAiApplyReasoning() add the parameter of the right API (reasoning.effort Responses, reasoning_effort Chat Completions, output_config.effort Anthropic) only for registry models; unknown models and custom endpoints get nothing. Gateway functions take an option purpose.

includes/ai/pricing-catalog.json holds USD prices per 1M tokens with source, date, cache rates and context tiers, and the exchange rate with its date. pricing.models["provider|model"] in the settings overrides it (USD or EUR); pricing.usdPerEur overrides the rate. includes/ai/pricing.php resolves the price (override, catalog, else unknown: never 0), normalizes provider usage (OpenAI cached tokens are part of the prompt, reasoning tokens part of the output; Anthropic cache fields are separate) and computes costs in cents with four decimals. A settled request stores model and priceBasis in its reservation; the audit log carries the same. Unknown prices are booked as an unpricedRequests count, not as cost.

The file also holds the sections agent (enable, limits per run, roles.editor rights, mediaFolder, creditInNews, sourcesInNews), research (brave.apiKey, allowDomains, denyDomains, respectRobots) and stock (pexels.apiKey) for the AI agent. They are written through raijuAiSaveSettings() (keys are only replaced when a new one is typed; sections a caller does not send are kept) and appear in load-ai-settings without keys (hasBraveApiKey, hasPexelsApiKey). The agent's own data lives under content/ai-agent/ (runs, change sets, staging, scan cache, rate counters) and content/ai-schema/ holds optional per-page restrictions; both are covered by the protection of content/.

The AI agent is described in AI-AGENT-GUIDE.md → "AI Agent (Frontend Copilot)". Its main stored objects: a run (content/ai-agent/runs/run_*.json: transcript, status, counters; only the owner and admins can read it), a change set (content/ai-agent/changesets/cs_*.json: operations, base revisions, sources, staged images, validation, apply result) and snapshots of every file an apply changes (content/ai-agent/snapshots/).

The dashboard uses the gateway for assistant chat, text generation, SEO/AEO field prefill, image generation, and image-to-image workflows. Local/private provider URLs require the explicit local-provider setting. AI controls are hidden when the AI module is disabled and rendered disabled when a configured capability lacks the required key or model.

Page Visibility (content/pages/{lang}_{slug}.json)

Pages are public by default. A page becomes password-protected when its JSON contains a private visibility object:

{
  "visibility": {
    "status": "private",
    "title": "Protected page",
    "text": "Enter the password to continue.",
    "passwordHash": "$2y$..."
  }
}

The dashboard writes this through the Content Editor's Access card. The API accepts a transient visibility.password, hashes it with password_hash(), and persists only passwordHash. Direct JSON edits should never store plaintext passwords.

includes/access-guard.php enforces both maintenance mode and private pages. Logged-in admins/editors bypass both locks. Private page access is stored in the session per page after a correct password. Maintenance mode renders a 503 response with Retry-After when an unavailable-until value exists; private pages render a 403 password form. Both lock responses send noindex headers.

Accessibility

Raiju Core ships with accessibility foundations for both visitor pages and admin editing surfaces:

  • Public templates include a skip link that targets #main-content; standard JSON pages set that id in includes/page.php, and includes/footer.php adds it to the first <main> as a fallback for custom templates.
  • Header, mobile, and footer navigation use labelled landmarks. Current language links use aria-current, mobile navigation exposes aria-hidden, and the hamburger button uses aria-expanded/aria-controls.
  • Core CSS keeps visible focus states and includes a global prefers-reduced-motion: reduce rule for transitions and animations.
  • Inline editor and media-manager dialogs use role="dialog", aria-modal, labelled titles, focus trapping, and focus return on close.
  • Toasts and editor status messages use role="status" or role="alert" with aria-live.
  • SEO health checks include missing image alt text, so accessibility issues surface in the same editorial workflow as metadata quality.

Site-specific templates should preserve these contracts. Use one meaningful H1, keep heading levels in order, write useful alt text for informative images, keep decorative images alt="", use native buttons/links/labels, and test keyboard-only navigation before launch.

Block Types

Defined in includes/block-types.php. Each block type has: label, category, icon, default values, and editor field definitions. Renderers are in includes/block-renderers/{type}.php.

text

  • Category: content
  • Fields: title (input), titleTag (select: h1-h6), content (wysiwyg), style (select: default, highlight)
  • Defaults: { "title": "", "content": "<p></p>", "titleTag": "h2" }

heading

  • Category: content
  • Fields: text (input), level (select: h1-h6), subtitle (input)
  • Defaults: { "text": "", "level": "h2", "subtitle": "" }

quote

  • Category: content
  • Fields: text (textarea), attribution (input), style (select: default, large)
  • Defaults: { "text": "", "attribution": "", "style": "default" }

list

  • Category: content
  • Fields: title (input), style (select: bullet, numbered), content (wysiwyg)
  • Defaults: { "title": "", "style": "bullet", "content": "<ul><li></li></ul>" }

image

  • Category: media
  • Fields: src (image upload), alt (input), caption (input), width (select: full, medium, small)
  • Defaults: { "src": "", "alt": "", "caption": "", "width": "full" }

card

  • Category: cards
  • Fields: title (input), content (textarea), image (image upload)
  • Defaults: { "title": "", "content": "", "image": "" }
  • Consecutive cards are automatically wrapped in a .cards-grid container by renderAllSections().

youtube

  • Category: media
  • Fields: title (input), videoId (input)
  • Defaults: { "title": "", "videoId": "" }

soundcloud

  • Category: media
  • Fields: title (input), trackId (input)
  • Defaults: { "title": "", "trackId": "" }

audio

  • Category: media
  • Fields: title (input), src (audio upload)
  • Defaults: { "title": "", "src": "" }

divider

  • Category: layout
  • Fields: none
  • Renders a horizontal rule.

spacer

  • Category: layout
  • Fields: height (select: sm, md, lg, xl)
  • Defaults: { "height": "md" }

Template API

All functions are defined in includes/content-loader.php.

Content Loading

loadContent(string $page): array

Load content from a JSON file. Returns ['sections' => []] if file not found.

$data = loadContent('en_home');

loadContentCached(string $page): array

Same as loadContent() but caches per request. Used internally by editable field functions.

getContentValue(string $page, string $key): mixed

Get a single top-level value from a page's JSON.

$title = getContentValue('en_home', 'title');

getNestedValue(array $data, string $dotKey): mixed

Navigate a nested array using dot notation.

$value = getNestedValue($data, 'hero.title'); // $data['hero']['title']

Editable Text

editableText(string $page, string $fieldKey, string $default = ''): string

Render an editable plain-text field.

  • Admin: <span class="editable-field" data-page="..." data-field="...">text</span>
  • Visitor: escaped text string
  • Supports __hidden sibling key to hide fields from visitors.
<h1><?php echo editableText('en_home', 'hero.title', 'Welcome'); ?></h1>

editableHtml(string $page, string $fieldKey, string $default = ''): string

Render an editable rich-text (HTML) field. Admin clicks to edit inline directly on the page; a floating mini-toolbar appears with Bold, Italic, Link, Clean formatting, and HTML source toggle.

  • Admin: <div class="editable-field editable-field-html" ...>sanitized HTML</div>
  • Visitor: sanitized HTML output
  • HTML is sanitized via sanitizeHtml() which allows safe tags and strips event handlers.
<div><?php echo editableHtml('en_home', 'hero.body', '<p>Default</p>'); ?></div>

editableLink(string $page, string $fieldKey, string $defaultText = '', string $defaultHref = '#', string $class = '', string $attrs = ''): string

Render an editable link. JSON stores {text, href} at the field key.

  • Admin: <a href="..." data-editable-link data-page="..." data-field="...">text</a>
  • Visitor: <a href="..." class="...">text</a>
<?php echo editableLink('en_home', 'hero.cta', 'Get Started', '#features', 'btn btn-primary'); ?>

Editable Images

editableImage(string $page, string $fieldKey, string $defaultSrc = '', string $defaultAlt = '', string $class = ''): string

Render an editable image. JSON stores {src, alt} at the field key.

  • Admin: <img ... data-editable-image data-page="..." data-field="...">
  • Visitor: <img src="..." alt="..." class="...">
<?php echo editableImage('en_home', 'hero.image', 'assets/images/hero.webp', 'Hero', 'hero__img'); ?>

Editable Lists

editableListAttrs(string $page, string $listKey, array $defaults = []): string

Return HTML attributes for an editable list container. The $defaults array defines field names and default values for newly added items.

<div class="feature-grid" <?php echo editableListAttrs('en_home', 'features.items', ['icon' => 'star', 'title' => 'New', 'desc' => '']); ?>>

editableListItemAttrs(string $page, string $listKey, int $index): string

Return HTML attributes for a single editable list item.

<div class="feature-card" <?php echo editableListItemAttrs('en_home', 'features.items', $i); ?>>

editableListItems(string $page, string $listKey): array

Get list items from JSON content. Returns an indexed array sorted by numeric key. Filters out hidden items for visitors.

$items = editableListItems('en_home', 'features.items');
foreach ($items as $i => $item) {
    echo editableText('en_home', "features.items.$i.title", 'Feature');
}

editableTextList(string $page, string $listKey, array $defaults = ['content' => '']): string

Render a list of editable HTML paragraphs with add/remove/reorder/hide controls. Each paragraph is an editableHtml() field with inline editing and the floating toolbar. Uses the standard list system internally.

echo editableTextList('en_about', 'intro.paragraphs');

JSON structure:

{
  "intro": {
    "paragraphs": {
      "0": { "content": "First paragraph..." },
      "1": { "content": "Second paragraph with <strong>formatting</strong>..." }
    }
  }
}

Field Visibility

isFieldHidden(array $data, string $fieldKey): bool

Check if a field is marked hidden via a {fieldKey}__hidden sibling key set to true. All editable field functions check this automatically.

Render Components

renderAllSections(string $page, bool $staggerCards = false): string

Render all sections from a page's JSON sections array. Automatically wraps consecutive card blocks in a .cards-grid div. When admin is logged in, each section gets editable overlay controls.

echo renderAllSections('en_about', true); // true = stagger animation on card grids

renderFeatureGrid(string $page): string

Render a grid of feature cards with icons. Reads features.items from JSON.

  • Item fields: icon (icon ID), title, desc
  • Available icons: database, edit, backup, image, globe, link, shield, feather, star, sparkles, zap, terminal, upload, server, folder, wand
echo renderFeatureGrid('en_home');

renderPricingTable(string $page): string

Render pricing plan cards. Reads pricing.plans from JSON.

  • Item fields: name, price, period, desc, features (newline-separated string), highlight (bool), badge, cta ({text, href})
  • Also reads pricing.vat_note for an optional footnote.
echo renderPricingTable('en_pricing');

renderFaqAccordion(string $page): string

Render a collapsible FAQ. Reads faq.entries from JSON.

  • Item fields: question, answer
  • Requires js/faq-accordion.js (auto-loaded by footer.php if present).
echo renderFaqAccordion('en_faq');

renderTeamGrid(string $page): string

Render a team/contributors grid. Reads team.members from JSON.

  • Item fields: name, role, bio, image ({src, alt})
echo renderTeamGrid('en_about');

renderGallery(string $page): string

Render an image gallery with lightbox. Reads gallery.images from JSON.

  • Item fields: src, alt, caption
  • Includes a lightbox overlay in the output.
echo renderGallery('en_gallery');

renderTimeline(string $page): string

Render a timeline/changelog. Reads timeline.entries from JSON.

  • Item fields: date, version, title, desc, status (released/upcoming)
echo renderTimeline('en_roadmap');

renderStats(string $page): string

Render key figures/statistics. Reads stats.items from JSON.

  • Item fields: value, label, desc
echo renderStats('en_home');

renderTestimonials(string $page): string

Render testimonial quote cards. Reads testimonials.items from JSON.

  • Item fields: quote, author, role
echo renderTestimonials('en_home');

renderComparisonTable(string $page): string

Render a feature comparison table. Reads comparison.rows from JSON. Columns are defined in getComparisonColumns().

  • Row fields: label, plus one value per column ID (use "yes", "no", or free text)
echo renderComparisonTable('en_showcase');

renderNewsList(int $limit = 0, string $lang = 'en'): string

Render a grid of news post cards. Reads from content/news/*.json. Posts are sorted by date descending.

echo renderNewsList(0, 'en'); // All posts in English
echo renderNewsList(3, 'en'); // Latest 3 posts

Custom Layout Pages vs Standard Pages

Standard pages only need a JSON file with a sections array. The router serves them via includes/page.php which calls renderAllSections(). No PHP template needed. To add per-page logic (external styles/scripts, page classes) without modifying core files, create includes/site-page-hook.php — it runs after $slug, $lang, $data, $basePath are set but before rendering.

Custom layout pages have a PHP template file at {lang}/page-slug.php with full control over HTML structure. They use editableText(), editableHtml(), editableImage(), editableLink(), and render components to keep content editable via the admin system. Templates can set $pageExternalStyles (array of CSS URLs) and $pageExternalScripts (array of JS URLs) to load external resources per page.

When to use which:

  • Standard: simple content pages (about, legal notices, basic articles)
  • Custom: landing pages, homepages, pages with specific layouts, pages using render components

CLI Tools

cli/make.php — Page Scaffolding

Generates page boilerplate with a single command:

php cli/make.php --slug=about --lang=en --title="About Us"                  # Standard (JSON only)
php cli/make.php --slug=services --lang=de --type=custom --title="Dienste"  # Custom (PHP + JSON)

Options: --slug (required), --lang, --type (standard/custom), --title, --description, --hide-nav, --dry-run, --force.

cli/convert.php — HTML to Raiju Converter

Converts a static HTML page into an editable PHP template + JSON content file + extracted CSS. See cli/README.md for full options.

Inline Editor System

When an admin is logged in ($_SESSION['admin_logged_in'] === true), the editable field functions add data-* attributes that the inline editor JavaScript uses to enable on-page editing.

How It Works

  1. includes/footer.php checks isAdminLoggedIn() and loads css/inline-editor.css + js/inline-editor.js
  2. The editor uses an explicit edit mode toggle ("Edit page" button in admin bar)
  3. body.edit-mode-active CSS class gates all editing outlines and overlays
  4. Changes are collected in memory (EditorConfig.contentData) and only saved when the user clicks "Save"
  5. Structural operations (add/delete sections, reorder list items) trigger an immediate save + page reload

Data Attributes

  • data-page + data-field on .editable-field elements (text, HTML)
  • data-editable-link + data-page + data-field on links
  • data-editable-image + data-page + data-field on images
  • data-editable-list + data-list-page + data-list-key + data-list-defaults on list containers
  • data-list-page + data-list-key + data-list-index on list items
  • data-hidden="true" on any element that is hidden from visitors but visible to admins

Navigation System

Auto-Discovery

Pages with JSON content files are automatically added to navigation by header.php. The system scans content/pages/{lang}_*.json and appends any page not already listed in $NAV_ITEMS. System partials (home, footer, sidebar, header) are excluded automatically.

To control which menus a page appears in, set "nav": ["header", "footer"] in its JSON file. Use "nav": [] to hide from all menus. Default (no field): ["header"]. The title for auto-discovered pages is read from the JSON title field, with a fallback to the titlecased slug.

includes/nav-config.php

Optional — only needed for explicit ordering, custom labels, or the language switcher's page mapping.

Two main data structures:

$PAGE_MAPPING - Maps page slugs to URL paths per language. Used by the language switcher.

$PAGE_MAPPING = [
    'home' => ['en' => '.', 'de' => 'de/'],
    'about' => ['en' => 'about', 'de' => 'de/ueber-uns'],
];

$NAV_ITEMS - Navigation items per language. Each item has href, label, and page (slug for active state highlighting). Pages listed here appear first; auto-discovered pages are appended after.

$NAV_ITEMS = [
    'en' => [
        ['href' => '.', 'label' => 'Home', 'page' => 'home'],
        ['href' => 'about', 'label' => 'About', 'page' => 'about'],
    ],
];

Language Switching

header.php automatically generates language switch links from $PAGE_MAPPING. If the current page exists in the target language (via JSON file), it links there. Otherwise it falls back to the home page of that language.

The primary language (SITE_LANG_DEFAULT) serves pages at the root (/about). Secondary languages use a prefix (/de/ueber-uns).

Admin API Endpoints

All endpoints are in admin/api.php. Authenticated via PHP sessions with CSRF token protection.

Page Management

Action Method Description
list-pages GET List all page JSON files
create-page POST Create a new page (params: page, title)
copy-page POST Copy page to another language
delete-page POST Move page to trash
duplicate-page POST Duplicate a page

Trash

Action Method Description
list-trash GET List trashed pages
restore-page POST Restore page from trash
delete-trash POST Permanently delete trashed page
empty-trash POST Permanently delete all trashed pages

Content

Action Method Description
load GET Load page JSON content (param: page)
save POST Save page JSON content (param: page, content)

Backups

Action Method Description
backups GET List backups for a page
restore POST Restore a backup
delete-backup POST Delete a specific backup
preview-backup GET Preview backup content
create-site-backup POST Create a manual full-site ZIP backup and issue a download token
download-site-backup GET Stream a site backup ZIP using a one-time token
restore-site-backup POST Restore a full-site ZIP in full or content mode
backup-status GET Return scheduled backup status, retention, storage, and remote target metadata
backup-list GET List stored full-site ZIP backups
backup-update-settings POST Save scheduled backup retention, storage limit, and remote target config
backup-delete POST Delete a stored full-site ZIP backup
backup-prepare-download POST Issue a one-time token for a stored ZIP
backup-upload-remote POST Upload an existing ZIP to enabled remote targets
backup-test-remote POST Test one configured remote target with a small upload
backup-dropbox-oauth-start / backup-dropbox-oauth-callback GET Dropbox OAuth PKCE connect flow
backup-google-oauth-start / backup-google-oauth-callback GET Google Drive OAuth PKCE connect flow
backup-onedrive-oauth-start / backup-onedrive-oauth-callback GET Microsoft OneDrive OAuth PKCE connect flow

Full-site backups are created by includes/backup-helper.php and can be run from cron via cli/backup.php --action=run. Scheduled backups use tiered retention (daily, weekly, monthly, yearly) plus an optional storage cap. Backup files are named {site-domain}-backup-YYYY-MM-DD_HHMMSS-{tier}.zip. Remote targets are stored under backup.remote_targets in content/settings.json; uploads are placed in a per-site subfolder below the configured remote path, secrets are masked in the dashboard, and secrets are scrubbed from content/settings.json when that file is written into a backup ZIP.

Events

Action Method Description
load-events GET Load all events
load-event GET Load single event
save-event POST Create or update event
delete-event POST Delete event
toggle-event-visibility POST Show/hide event

News/Blog

Action Method Description
load-news GET List all news posts
save-news POST Create or update news post
toggle-news-status POST Show/hide news post
delete-news POST Delete news post

Media

Action Method Description
list-images GET List uploaded images
upload-image POST Upload image file
delete-image POST Delete image file
list-audio GET List uploaded audio files
upload-audio POST Upload audio file
delete-audio POST Delete audio file

Mail & Settings

Action Method Description
load-mails GET Load form submissions and available form metadata
mark-mail-read POST Mark single mail as read
update-mail-flags POST Update mail read/starred flags
mark-all-mails-read POST Mark all mails as read
delete-mail POST Delete a mail
delete-read-mails POST Delete all read mails
unread-mail-count GET Get count of unread mails
change-password POST Change admin password
load-settings GET Load site settings
save-settings POST Save site settings

Forms

Action Method Description
list-forms GET List content/forms/*.json definitions
load-form GET Load one normalized form definition
save-form POST Save one form definition from the Forms settings panel

Public form endpoints live in api/:

Endpoint Method Description
api/form.php?form={id} GET Lazy-render a JSON form when content/forms/{id}.json exists, otherwise fall back to whitelisted legacy forms
api/form-submit.php POST Validate, notify, and store JSON form submissions
api/contact.php POST Legacy contact form endpoint; still stores compatible formId / formLabel metadata

Theming

CSS Custom Properties

All design tokens are defined as CSS custom properties in css/style.css:

:root {
    /* Colors — neutral blue primary */
    --color-primary: #2563eb;
    --color-primary-dark: #1d4ed8;
    --color-primary-dark-muted: #1e40af;
    --color-primary-light: #60a5fa;
    --color-primary-really-dark: #172554;
    --color-primary-btn: radial-gradient(ellipse at 50% 0%, #3b82f6 0%, #2563eb 70%);
    --color-primary-btn-hover: radial-gradient(ellipse at 50% 0%, #60a5fa 0%, #2563eb 70%);
    --color-secondary: #60a5fa;
    --color-text: #171717;
    --color-text-secondary: #525252;
    --color-background: #ffffff;
    --color-background-dark: #eff6ff;
    --color-background-darker: #dbeafe;
    --color-background-section: #f5f5f4;
    --color-background-card: #ffffff;
    --color-white: #ffffff;
    --color-black: #0a0a0a;
    --color-border: rgba(0, 0, 0, 0.08);

    /* Dark theme palette */
    --color-bg-dark: #0c0c0c;
    --color-bg-dark-elevated: #141414;
    --color-bg-dark-card: #1c1c1c;
    --color-bg-light: #fafaf9;
    --color-text-on-dark: #e5e5e5;
    --color-text-on-dark-muted: #a3a3a3;
    --color-border-dark: rgba(255, 255, 255, 0.06);
    --color-border-light: rgba(0, 0, 0, 0.06);

    /* Accent — brand gradients */
    --gradient-brand: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
    --gradient-brand-text: linear-gradient(90deg, #60a5fa 0%, #3b82f6 50%, #60a5fa 100%);

    /* Glass effect */
    --glass-bg: rgba(255, 255, 255, 0.03);
    --glass-border: rgba(255, 255, 255, 0.06);

    /* Typography — system fonts */
    --font-display: system-ui, -apple-system, sans-serif;
    --font-body: system-ui, -apple-system, sans-serif;
    --font-mono: 'SF Mono', 'Fira Code', Menlo, Consolas, monospace;

    /* Spacing */
    --spacing-xs: 0.5rem;
    --spacing-sm: 1rem;
    --spacing-md: 2rem;
    --spacing-lg: 4rem;
    --spacing-xl: 6rem;

    /* Transitions */
    --transition-fast: 0.2s ease;
    --transition-normal: 0.3s ease;
    --transition-slow: 0.5s ease;

    /* Layout */
    --header-height: 80px;
    --header-bg: rgba(255, 255, 255, 0.95);
    --container-max: 1400px;
    --container-narrow: 800px;
}

Dark/Light Theme

The theme toggle sets data-theme="dark" or data-theme="light" on <html>. Theme preference is stored in localStorage under key site-theme. A blocking <script> in <head> applies the stored theme before first paint to prevent FOUC.

Custom Fonts

  1. Place font files in assets/fonts/
  2. Define @font-face rules in css/fonts.css
  3. Uncomment the <link rel="stylesheet" href="css/fonts.css"> in includes/header.php
  4. Update --font-display and --font-body in css/style.css

Example fonts are provided in examples/fonts/.

Form System

Raiju has two public form paths:

  1. JSON-backed forms are the current default for simple forms. Definitions live in content/forms/*.json, render through includes/forms.php, and submit to api/form-submit.php.
  2. Legacy PHP forms such as includes/contact-form.php still submit to api/contact.php and remain supported for older templates or highly custom markup.

includes/forms.php provides the JSON form API:

Function Purpose
raijuFormLoad($id) Load and normalize one form definition
raijuFormSave($form) Normalize and write a form JSON file
raijuFormsList() List available forms for settings and inbox filters
raijuFormRender($id, $options) / raijuForm($id, $options) Render a visitor-facing form
raijuFormValidateSubmission($form, $lang) Validate required fields and email fields
raijuFormsSaveSubmission($submission) Store a submission in content/mails.json
raijuFormsSendNotification(...) Send SMTP/sendmail notifications using content/settings.json email config

The renderer emits .contact-form.raiju-json-form, a hidden form_id, a one-time token, a honeypot, .btn-text / .btn-loading, .form-feedback, and a .raiju-form-grid wrapper. The shared JavaScript in includes/footer.php handles AJAX submission for both legacy and JSON forms.

Submissions are stored in content/mails.json with at least:

{
  "id": "mail_...",
  "formId": "contact",
  "formLabel": "Kontaktformular",
  "name": "Example",
  "email": "hello@example.com",
  "occasion": "Kontaktanfrage: Example",
  "message": "Message body",
  "fields": [
    { "key": "name", "label": "Name", "type": "text", "value": "Example" }
  ],
  "timestamp": "2026-06-02T12:00:00+00:00",
  "status": "local_only",
  "read": false,
  "starred": false
}

The dashboard messages view is labelled as contact/form submissions, supports a form filter, shows the source form in the table, and displays structured fields[] values in the detail view. Older messages without formId are normalized to the contact form for display.

The Forms settings panel intentionally stays narrower than a full form builder: admins can switch forms through a select, create a new simple definition, edit form metadata, enable/disable local storage and email notification, and edit field type, key, label, placeholder, required state, width, and options. Conditional display logic and custom frontend behaviour should remain in site-owned templates or scripts.

News/Blog System

News posts are individual JSON files in content/news/. Each file contains metadata (title, excerpt, date, author, image, slug, lang) and a sections array for the post body.

Listing: renderNewsList($limit, $lang) renders a card grid of posts. Posts are sorted by date descending and filtered by language.

Single post: The router maps /en/news/slug to {lang}/news-post.php, which loads the matching JSON file and renders its sections.

Admin: Posts are created and managed via the admin dashboard, which uses the save-news, load-news, toggle-news-status, and delete-news API actions.

File Security

.htaccess Rules

  • content/, backups/, and trash directories are blocked from direct HTTP access
  • The router.php dev server replicates these blocks
  • A proxy that serves static files itself (nginx on HestiaCP, Plesk's "Serve static files directly by nginx") bypasses .htaccess; block the same folders in nginx, see ROUTING.md
  • cli/*.php answer web requests with 404 in case a server ignores .htaccess

Security Self-Check

includes/security-check.php feeds System status → Security. The POST action security-check (administrators, CSRF) creates short-lived probe files with random names in content/ (.json), backups/ (.zip) and the existing media trash folders, plus a public control file in assets/images/. It requests them through the site's own address (cURL, 3–5 s timeouts, unverified TLS only as a fallback for self-signed staging certificates) and evaluates only the status code: 2xx is public, 403/404/410 is blocked. The control file must return 2xx, so a wrong address cannot pass as protected. A public content/ or backups/ is critical and shows the dashboard banner; public trash folders alone are a warning. The action releases the session lock first and always deletes its probe files. Results are cached per address in content/security-check.json (12 hours when protected, 10 minutes when public, 1 hour otherwise) under a non-blocking lock; the GET action system-status only reads that cache. Local and intranet hosts run the probe on request ("Check now"); the single-threaded PHP development server is skipped. The same response reports dashboard use without HTTPS, plain HTTP without a redirect, the session cookie's Secure flag, a proxy hiding visitor IP addresses, display_errors (recorded before the API disables it), the development login and the end of PHP's upstream security support.

Authentication

  • bcrypt password hashing (password_hash / password_verify)
  • IP-based brute force protection (file-backed rate limiting)
  • CSRF tokens on all POST requests
  • Secure session cookies (httponly, samesite: Strict)
  • Session timeout enforced on every API request

Content Sanitization

sanitizeHtml() strips all but safe HTML tags and removes on* event handlers and javascript: URLs from user-provided content.

Core modules and shared state (September 2026)

admin/api.php remains the single authenticated endpoint. Its bootstrap loads shared helpers; admin/api/contracts.php enforces request method, CSRF and administrative boundaries, and acquires the revision locks. routes.php maps existing action names to twelve domain handlers. Direct handler requests return 404. Dashboard workflows live in admin/dashboard/scripts/; PHP assembles them in the original order in one classic script, preserving shared scope, inline handlers and the no-build installation. New workflows should use a separate fragment rather than expanding the main dashboard template.

load and load-settings return a top-level revision (SHA-256 of stored bytes, missing for an absent file). save and save-settings require the same value in their POST form: missing revisions return 428, changed revisions return 409. The lock spans read, comparison and atomic replacement. js/revision-client.js tracks revisions independently for each resource and displays a read-only comparison, download and explicit reload on conflict. It never retries a stale write automatically. Integrations using these write actions must first load the resource and send the returned revision. Field-level copilot changes retain signed hash checks and share the page lock. Automatic fallback generation adds only a still-missing field in a JSON transaction.

Amounts are kept as cents with four decimals and rounded only for display. Reservations use the worst case of the model that is really used (estimated input, the output limit plus thinking headroom, tier rates).

AI reservations live beside consumption in content/ai-usage.json, under one stable lock. Requests move from reserved to settled, released or uncertain. Reserved and uncertain estimates count against limits. Settlement is idempotent and uses the request's original accounting date. These are configured estimates, not an imported provider invoice. System status offers explicit reconciliation after 15 minutes without progress. Verify the provider before resolving a request. No automatic retry resubmits a paid POST after an ambiguous transport response.

Image workers hold a separate process lock. Kie task IDs are persisted as soon as submission returns; known tasks resume polling after worker interruption. An ambiguous submission without a task ID requires manual provider inspection. Polling errors can requeue the same job (60-second backoff, at most ten attempts); the dashboard and existing CLI runner use the same implementation. Provider changes cannot redirect a resumed task. includes/ai/model-catalog.json is the shared source for provider capabilities, image aliases and UI model presets. OpenRouter's existing live catalog cache remains available.

Analytics format 2 stores the active day in content/analytics/days/YYYY-MM-DD.json. Historical days older than 90 days lose visitor/session hashes and move into monthly files under archive/. The restartable legacy import retains the old analytics.json as aggregates without identifiers. Summary queries read only the needed date range and cache results for 60 seconds; today's file revision invalidates that cache immediately. The privacy setting analyticsEnabled defaults to true for compatibility, while explicit false stops collection and preserves existing totals. Backups include the new analytics directory.

Run python3 tests/run-smoke.py for isolated offline checks. Browser development requires npm ci --prefix tests and tests/node_modules/.bin/playwright install chromium, followed by python3 tests/browser-check.py, the responsive-check.js and revision-check.js variants. These packages are not website dependencies. GitHub Actions runs the suite on PHP 8.4/8.5 and Chromium on pull requests.