Raiju/AI-AGENT-GUIDE.md
2026-10-25 06:05:42 +03:00

105 KiB
Raw Permalink Blame History

Raiju CMS - AI Agent Guide

Raiju turns any HTML or PHP page into an editable website — no database required. Take a hand-crafted or AI-generated (vibe-coded) page, replace hardcoded text, images, and links with Raiju's PHP helper functions, and you get a fully functional CMS with inline editing. Content is stored as JSON files, the editable elements are marked with data-* attributes that the inline editor recognizes, and visitors see clean HTML with no editor markup. This guide covers everything you need to make pages editable.

This is the canonical, tool-neutral guide for AI coding agents. AGENTS.md and CLAUDE.md are intentionally short entry points for tools that auto-load those filenames; keep detailed implementation guidance here to avoid contradictory instructions.

Quick Reference

What Where
Page templates {lang}/page-slug.php (e.g. en/about.php)
Content JSON content/pages/{lang}_{slug}.json (e.g. content/pages/en_about.json)
News posts content/news/{slug}.json
Footer content content/pages/footer.json
Form definitions content/forms/*.json
Site settings content/settings.json
Maintenance/private access includes/access-guard.php + content/settings.json / page visibility
Menu registry content/menus.json
Navigation includes/nav-config.php
Menu helpers includes/menu-helpers.php
Migration redirects content/redirects.json (copy from content/redirects.example.json)
Redirect helper includes/redirect-helper.php
Site page hook includes/site-page-hook.php (optional, survives core updates)
Block types includes/block-types.php
Template API includes/content-loader.php
Block renderers includes/block-renderers/{type}.php
Base stylesheet css/style.css (custom properties at top)
Component styles css/components.css
Page-specific styles css/page-{slug}.css (generated by converter)
Example templates examples/templates/
Example content examples/content/
HTML converter cli/convert.php
Page scaffolding cli/make.php
Events content/events.json
Email config content/settings.json → email section
SMTP mailer api/SmtpMailer.php
JSON form renderer includes/forms.php
Public form submit API api/form-submit.php
Legacy contact form API api/contact.php
AI gateway includes/ai/ai-helper.php
AI settings content/ai-settings.json (no keys), content/ai-secrets.json (API keys only, chat and image services)
AI image services includes/ai/image-services.json (registry), includes/ai/image-services.php (adapters)
AI usage/audit content/ai-usage.json, content/ai-audit/*.jsonl
AI image history content/ai-image-history.json
Deploy script deploy.example.sh (copy to deploy.sh and configure)
Security self-check includes/security-check.php (Dashboard → System status → Security)
Backup CLI cli/backup.php (cron-friendly site backup runner)
Backup helper includes/backup-helper.php

Development Server

php -S localhost:3000 router.php

For large image uploads raise the CLI limits (the default upload_max_filesize is often 2M):

php -d upload_max_filesize=20M -d post_max_size=25M -S localhost:3000 router.php

The router.php handles clean URLs, language routing, and news post URLs without requiring Apache.

For routing changes, login destinations, new handler directories and uploads, follow Routing and deployment verification. The development server does not execute .htaccess or Apache's directory handling. Test both slash variants and the actual login redirect chain under Apache before claiming production routing is verified. In particular, admin/dashboard.php shares its name with the admin/dashboard/ fragment directory; keep the explicit PHP login destination and compatibility aliases described in that guide.

After uploading a site, open Dashboard → System status → Security and report its result. An nginx proxy that serves static files itself bypasses .htaccess and can expose content/ (password hashes, SMTP password, form submissions) and backups/. The check detects this with short-lived probe files and shows the nginx rules; see Hosting proxies and internal folders. Test existing files only: nginx passes missing files to Apache, which answers 403.

JSON-Backed Public Forms

Raiju supports simple, flat-file public forms stored in content/forms/*.json. This is the preferred path for vibe-coded sites: an agent can generate the form structure once, and non-technical admins can later adjust labels, placeholders, required state, select/radio options, column widths, success text, and notification behaviour in Dashboard -> Settings -> Forms.

The goal is not a full visual form builder. Keep complex conditional logic, custom validation, and page-specific behaviour in the site template or a site-owned script. Use JSON forms when users need safe edits to an existing form.

Form JSON

{
  "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": "message", "type": "textarea", "label": "Nachricht", "required": true, "width": 12 }
  ]
}

Supported field types are text, email, tel, textarea, select, radio, checkbox, date, time, hidden, heading, and note. Supported widths are 3, 4, 6, 8, and 12 on a 12-column grid. select and radio fields use options entries shaped as { "value": "...", "label": "..." }.

Rendering

For a direct server-side render:

<?php
require_once __DIR__ . '/../includes/forms.php';
echo raijuForm('contact', ['basePath' => $basePath, 'lang' => $currentLang]);
?>

For lazy loading, use the existing placeholder. api/form.php?form=<id> renders JSON forms when a matching content/forms/<id>.json exists:

<?php
echo raijuLazyFormPlaceholder('contact', [
    'basePath' => $basePath,
    'params' => ['lang' => $currentLang, 'basePath' => $basePath],
]);
?>

JSON forms submit to api/form-submit.php. The renderer adds the one-time token, honeypot, form_id, loading spans, and .form-feedback markup expected by the shared footer JavaScript. Submissions are stored in content/mails.json with formId, formLabel, and a structured fields[] array; the dashboard inbox can filter by form.

Legacy and Custom PHP Forms

The legacy includes/contact-form.php and api/contact.php continue to work for older templates. For fully custom PHP forms that do not use JSON rendering, include includes/form-protection.php, call raijuFormProtectionFields($id), validate with raijuValidateFormToken(), and store messages with the same formId/formLabel metadata if they should appear clearly in the inbox.

AI Gateway

Raiju core AI features must use includes/ai/ai-helper.php instead of calling LLM or image-generation vendors directly. The gateway stores provider settings in content/ai-settings.json, supports OpenAI-compatible providers, keeps API keys server-side in content/ai-secrets.json (never in the settings file, which is safe to commit), enforces feature flags, daily/monthly limits, token caps, rough cost estimates, and writes audit events to content/ai-audit/*.jsonl.

Use the existing admin API actions (ai-chat, ai-generate-text, ai-generate-image, ai-image-history, clear-ai-image-history, load-ai-settings, save-ai-settings, ai-test) for dashboard features. API keys are never exposed by load-ai-settings; it only returns hasApiKey and provider metadata. Do not add direct browser-to-provider calls.

Provider credentials are stored per provider, so admins can switch between OpenAI, OpenRouter, Anthropic (Claude), Ollama, LM Studio, or another OpenAI-compatible endpoint without replacing keys. The Anthropic provider is translated to and from the Messages API inside the gateway (including streaming) so all callers keep one OpenAI-style request/response shape; it serves chat and text only; images then come from the separate image service. Chat replies can stream over Server-Sent Events with an automatic fallback to the buffered JSON response. Local/private URLs (localhost, private IP ranges, or self-hosted gateways) must only be accepted when the explicit local-provider setting is enabled.

Images come from an image service that is chosen apart from the chat provider (imageService in content/ai-settings.json, section "Images" in the AI settings). Its default inherit uses the chat provider's own image models (not available with Anthropic); the other choices are OpenAI, OpenRouter, Kie.ai, fal.ai, Comet API and custom (any OpenAI-compatible /images/generations endpoint with base URL, model id, size mode size|aspect_ratio|none, authentication bearer|x-api-key and optional extra parameters). Resolve it with raijuAiImageServiceResolve($settings); never read imageModel for the effective model, use raijuAiEffectiveImageModel(), and ask raijuAiImageServiceUsable() / raijuAiImageUnavailableReason() whether images can be made. Each service has its own key in content/ai-secrets.json (imageServices.<id>.apiKey); OpenAI, OpenRouter and Kie.ai fall back to the chat key of the same provider, only when the chat provider's base URL has the service's host (raijuAiImageChatKey()), so a key never goes to a host it was not made for. Remove one with ai-clear-secret (image:<id>).

Adapters live in includes/ai/image-services.php and share one input (prompt, count, aspectRatio, size, imageScale, outputFormat, referencePaths, ...) that raijuAiGenerateImageExecute() builds; they return the stored image paths. Existing adapters: openai-images, openrouter-chat, kie-task, fal-queue (submit, poll status_url, fetch response_url; tasks sit in the request ledger so a stopped job resumes) and gemini-generate (Comet's generateContent route). To add a service, add an entry to includes/ai/image-services.json (adapter, base URL, keyFrom, models) and, for a new protocol, an adapter function plus its case in raijuAiImageAdapterRun(). To add a model, add it to the service's models with what it accepts (aspects, aspectParam or sizeParam with sizePresets, countParam/maxCount, formatParam/formats, resolutionParam/resolutions, references, price). Set verified to the date you checked the parameters against the provider's official documentation, or false when you did not; never guess a parameter or a price. A model that is not listed uses the defaults of its service. Result images are fetched with raijuNetSafeFetch() (raijuAiImageFetchBinary()), addresses a provider returns for polling are followed only on the service's own host, keys never enter audit entries, jobs, history or error messages, and raijuAiProviderErrorMessage() is the one place that reads provider error bodies.

The registry entry of a model may carry its own adapter and a basePath below the service address (Comet: Nano Banana over gemini-generate, GPT Image 2.5 over openai-images below /v1 with an openai.params list of what the route takes, Seedream over comet-task), and for Kie.ai a kie block with the task id and the separate image-to-image id, resolutionMap (Kie's Seedream takes quality basic/high for 1K/2K), formatAliases, aspectAuto and references.param. raijuAiKieTaskInput() builds a Kie task from it; raijuAiImageTaskRun() is the queue runner shared by fal.ai and Comet's asynchronous route (submit what the ledger does not know, poll, download, clean the ledger).

Reference images: the agent's generate_image takes references (a mediaRef of the run or a libraryPath); raijuAgentGenerateReferences() checks them when the tool is called, and raijuAiImageServiceSupportsReferences() says whether the service can use them (OpenAI-style /images/edits, OpenRouter and Kie as before, fal.ai only for models with an editModel, Comet by inline_data). Results are labelled modified, not generated.

Live model lists: raijuAiImageLiveModels() (fal.ai GET /v1/models?category=text-to-image&status=active, Comet GET /api/models, entries of model_type image) feeds the model suggestions through the admin action ai-image-models; add a service with a documented list endpoint to RAIJU_AI_IMAGE_LIVE_SERVICES with a parser. Tests redirect the list hosts with RAIJU_AGENT_TEST_NET.imageListBases.

Prices per image: raijuAiImagePrice() takes the admin's price for the model (pricing.imageModels["<service>|<model>"]), then the registry's, then the default price per image (pricing.imageCentsPerRequest), otherwise the cost is unknown (reservation 0, priced:false). An entry is {perImage?, currency, byResolution?: {1K, 2K, 4K}, byQuality?: {low, medium, high}}; a request is priced by its resolution step (raijuAiImageRequestResolution()), then its quality, then perImage.

Generated images are validated and saved under assets/images/generated/, are visible in the Media Library, and are indexed in content/ai-image-history.json with prompt, model, options, response status, and local media paths. OpenAI image edits use multipart image uploads; OpenRouter-compatible image generation uses the configured model id and may have provider-specific option support. Never automatically retry an ambiguous paid POST, even when no response bytes arrived. Resume known provider task IDs through the request ledger; outcomes without a task ID need reconciliation before another submission.

Limits are guides, not hard walls (raijuAiCheckLimits()): a request that starts below a limit may finish, and requests of an agent run under way (agent_run_*_N with N > 1, agent_read_*) may use up to 10 % more of the daily request limits and the monthly budget. The first request of a run, standalone requests and image requests stay strict. raijuAiUsageSummary()['warnings'] lists limits at 80 % or reached; the dashboard, the AI settings and the assistant show them. OpenAI-compatible requests go through raijuAiCompatRequest(), which sends max_completion_tokens to api.openai.com and learns per model (content/ai-model-quirks.json) when a provider rejects max_tokens, max_completion_tokens or temperature. Agent turns with tools (raijuAiChatWithTools()) use the Responses API (/responses, store: false, encrypted reasoning items carried in the assistant message as reasoningItems) for api.openai.com and for models a host refused on chat/completions with "use /v1/responses"; everything else stays on chat/completions.

Every gateway call serves a purpose (purpose option: chat for assistant, agent and full drafts, text for field suggestions, rewrites and SEO; raijuAiGenerateText() defaults to text). The purpose and the model decide the reasoning effort through the registry includes/ai/model-capabilities.json (raijuAiReasoningPlan()); never add a reasoning parameter for a model that is not listed there, and never send temperature to a model that the registry marks omitTemperature. Costs come from includes/ai/pricing.php: use raijuAiCostForUsage() with the model that was really used, never a global price; an unknown price is priced: false, not 0. Amounts are cents with four decimals (raijuAiAddCents() to sum).

The dashboard assistant (mode=dashboard on ai-copilot-chat) runs one tool turn with open_section, show_location and change_setting (includes/ai/dashboard-actions.php). Calls are validated per role and returned as actions cards; nothing is changed by the chat. The card's button calls window.RaijuDashboardActions.run() (admin/dashboard/scripts/settings.php), which fills the existing theme or language form and submits it. To allow another setting, add it to RAIJU_DASHBOARD_SETTINGS and raijuMapActionEntries(), validate it in raijuDashboardActionFromCall() (accept the words people and models use, like raijuDashboardResolveLanguage()), add its texts, and handle it in the runner. Whatever the assistant cannot or may not do must end in a link card (type: link, a same-site path that opens in a new tab with rel="noopener"), never in "see the settings": raijuDashboardFinishTurn() adds cards found from the person's own words when the model offered none (no tool calling, an unusable value, an editor asking for an administrator setting). The dashboard system prompt always carries the map (raijuDashboardKnowledgePrompt()), with or without tools.

Raiju map (includes/ai/raiju-map.php)

The one place that knows where things are in the dashboard: areas (raijuMapSections()), settings tabs (raijuMapSettingsTabs()) and the things people look for (raijuMapEntries(): id => area, tab, label key, synonyms, English hint). Names are label keys of admin/lang/*.json, so breadcrumbs ("Settings › Language › Admin Language") exist in all admin languages; free text is matched against the names in every language plus the synonyms (raijuMapLookup()). Links are built from the script path of the request (raijuMapDashboardUrl()) with the dashboard's own hashes (#settings/language). Roles and switched-off modules are respected: an editor gets the place without a link, a switched-off module points to Settings › Modules. When you add a dashboard area, a settings tab or a setting people ask about, add the entry here; tests/raiju-map-smoke.php compares the map with admin/dashboard.php, requires every name in all nine languages and rejects one synonym on two entries.

The package preview (?rj_agent_preview={id}&rj_agent_return={local path}) opens in the same tab. Its bar only holds links back to the return path: #rj-agent-open and #rj-agent-apply={id}. js/ai-agent.js reads that hash once the open package is loaded and opens the panel or its own apply confirmation. Never add cross-tab messaging or CSRF tokens to the preview page.

Dashboard AI UI should honor both global module visibility and AI feature flags. If AI features are disabled, hide AI buttons and tools throughout the admin UI. If AI is enabled but a capability lacks a model or key, show a clear disabled state instead of a failing action.

Frontend AI Assistant

The frontend Copilot is the logged-in editor chat widget loaded by includes/footer.php through js/ai-copilot.js and css/ai-copilot.css. It must remain an editor-facing orchestration layer, not a direct AI or file access layer.

Core files:

  • includes/ai/copilot-context.php builds the safe page/content manifest, validates suggested field values, normalizes image paths, creates/publishes page/news/event drafts, and exposes role-derived Copilot permissions. It also exposes a compact knowledgeBase with curated Raiju guidance from local product/agent docs. Keep this summary bounded and intentional; do not stream arbitrary repository files, PHP templates, or raw server documentation into the frontend Copilot context. Standard-page sections[] entries should be traversed into concrete manifest paths such as sections.0.content and sections.2.src; do not collapse the whole sections array into a single AI target. Keep structural metadata out of writable Copilot targets: page/lang identity, timestamps, publication flags, section ids, section types, layout size/level, and variant/style controls should not be suggested or applied by chat. Visibility flags such as field__hidden, section hidden, and status fields should also stay out of generic field suggestions; hide/show must use the dedicated signed visibility action instead of an ordinary boolean proposal.
  • admin/api.php owns all Copilot actions: context, chat, field suggestions, HTML formatting, visibility previews/applies, confirmed field apply, undo, content draft/create/publish, and image generation.
  • js/ai-copilot.js renders the chat panel, preview cards, confirmations, image variant selection, alt-text editing, undo, and publish controls. If window.RJ_ADMIN_API_URL is missing, it must infer admin/api.php from its own script URL so nested frontend pages still post to the right endpoint.
  • css/ai-copilot.css styles the widget independently from public site CSS.
  • admin/lang/*.json should contain user-facing Copilot strings. The widget uses window.RJ_LANG with safe English fallbacks.

Safety rules:

  • Never expose provider API keys, raw PHP templates, server files, password hashes, unrestricted JSON, or local provider internals to the model.
  • Browser code must only call admin/api.php; provider requests go through includes/ai/ai-helper.php.
  • Every write must require an authenticated session, CSRF, a Copilot permission, and a server-side manifest match. The model output is untrusted.
  • Previewed field and image proposals must carry a server-side signature over page, path, type, current hash, and allowed value hashes. Apply actions must reject unsigned proposals or values outside the signed preview.
  • Field updates are limited to manifest-supported field types. Text, HTML, link, boolean, known select values such as seo.robots, and image replacements have type-specific validation.
  • When a frontend editable link is selected, js/ai-copilot.js should resolve the instruction to the matching manifest subfield: .href/.url for target changes, .text/.label/.title for label changes. Do not apply AI output to the whole link object.
  • Simple selected-field HTML formatting requests such as bold, italic, and underline should use ai-copilot-format-html / raijuCopilotBuildHtmlFormatProposal() so they become signed preview proposals without spending a model call or bypassing confirmation.
  • Explicit selected-field hide/show requests should use ai-copilot-visibility and ai-copilot-apply-visibility. Sign the page/path/action/current-state, write only the matching path__hidden flag, create a backup, audit the hidden-state change, and return an Undo token bound to that hidden path.
  • HTML suggestions must pass the existing sanitizer. Links must reject unsafe protocols. Image paths must stay under /assets/images/ and point to an existing raster media file (jpg, jpeg, png, webp, gif). Do not allow SVG replacement or reference images through the Copilot image workflow.
  • Image draft selection must stay inside the Copilot panel: editors choose the target image field and generate/edit mode before ai-copilot-generate-image is called. Do not use native prompt()/confirm() dialogs for this image selection flow.
  • Image draft provider options must also stay inside the Copilot panel and be allowlisted server-side before they reach the provider gateway. Current Copilot options are size/orientation, variant count, raster output format, and quality.
  • Confirmed page changes must create backups and return a signed, limited undo token for the same page/path. Do not turn the Copilot into a general backup browser.
  • Copilot proposal audit summaries should store path/type/current hash/value hash metadata, not raw generated values. Use raijuCopilotProposalAuditSummary() rather than logging proposal payloads directly.
  • Content creation must default to safe draft states: pages private/no nav, news hidden, events hidden. Publishing requires a separate explicit confirmation and Copilot publish permission.
  • Content draft previews must be signed before create actions accept them. Never accept a page/news/event creation payload that bypasses the preview signature.
  • Image generation is available only when the AI module, AI settings, image feature flag, image model, and target image field all allow it. Editors choose whether to use the current field image as an edit reference or generate a new image from prompt/context only.
  • Copilot actions should use raijuCopilotAssertBurstLimit() after session and CSRF checks for session-scoped burst protection. This complements the AI gateway's daily request, token, and budget limits; it does not replace them.
  • Write and image actions should call raijuAiAudit() with enough metadata to debug what happened without logging secrets.

Verification for Copilot changes:

php -l includes/ai/copilot-context.php && php -l includes/ai/ai-helper.php && php -l admin/api.php && php -l includes/footer.php
node --check js/ai-copilot.js
php tests/copilot-smoke.php
node tests/copilot-api-smoke.js
node tests/copilot-js-smoke.js
php tests/copilot-i18n-smoke.php
find . -maxdepth 3 \( -path './.git' -o -path './relaunch' \) -prune -o -name '*.json' -print | xargs -n 20 php -r 'foreach (array_slice($argv,1) as $f) { json_decode(file_get_contents($f), true); if (json_last_error() !== JSON_ERROR_NONE) { fwrite(STDERR, $f . ": " . json_last_error_msg() . PHP_EOL); exit(1); } }'
git diff --check -- AI-COPILOT-GOAL.md AI-AGENT-GUIDE.md includes/ai/copilot-context.php includes/ai/ai-helper.php admin/api.php includes/footer.php js/ai-copilot.js css/ai-copilot.css tests/copilot-smoke.php tests/copilot-api-smoke.js tests/copilot-js-smoke.js tests/copilot-i18n-smoke.php admin/lang/*.json

When a configured local site is available, also run a browser check as a logged-in editor: confirm that the widget appears only when AI is available, field suggestions show preview cards before saving, apply creates a backup and undo works, page/news/event creation starts as draft/private/hidden, publish requires confirmation, and image generation respects the edit-vs-generate choice.

AI Agent (Frontend Copilot)

The agent is an optional mode of the assistant chat: on the website (the frontend Copilot) and, since 1.0.0, in the dashboard, where the "Assistant" card of the home tab embeds the chat (RaijuAiCopilot.embed(host); syncAssistantEmbed() in admin/dashboard/scripts/overview-ai.php moves it between the card and the floating bubble when the tab changes). js/ai-agent.js reads window.RJ_AI_AGENT (set by includes/footer.php and admin/dashboard.php under the same conditions) and remembers its mode apart for the two. The editor describes a goal in the chat; the server works through it with a fixed set of tools, stages all changes in a change set (a package of operations) and presents it. The editor reviews it (before/after, images, sources, checks), can open a preview and applies it with one confirmed click. Nothing is written live by the agent. Classic Copilot flows stay available through the Agent/Classic switch and are the fallback when the provider cannot call tools.

The agent is off by default. Admins enable it in Dashboard → Settings → AI Settings → "AI agent" (limits per run, what editors may do, Brave and Pexels keys, media folder, credit behaviour, "clear page scan cache"). It needs a provider/model with tool calling (OpenAI-compatible and Anthropic are supported; Kie.ai is not) and the agent code reads only the agent, research and stock sections through raijuAiReadStoredSettings() (settings file with content/ai-secrets.json merged in; never read the files directly). API keys of these sections are never returned by load-ai-settings; an empty key field on save keeps the saved key.

Files (all under includes/ai/agent/ unless noted):

File Role
agent-config.php, settings.php Settings, rights (raijuAgentCan()), availability. Light enough for every page view.
tool-gateway.php raijuAiChatWithTools(): tool calling for OpenAI and Anthropic with argument validation, paired history, per-turn request ledger.
net.php raijuNetSafeFetch(): the only way to fetch external URLs (public addresses only, pinned DNS, manual redirects, size/type limits).
render-scan.php, capabilities.php, schemas.php Render scan of a page template to a capability map (fields, lists, limits). Site overrides in content/ai-schema/.
changeset*.php, preview.php Operations, validation, render check, apply with locks/backups/journal, undo, publish, owner-only preview.
run.php, tools.php, prompt.php The loop: step requests from the browser, tool whitelist, system prompt.
safety.php, safety-patterns.php The safety check: blast radius of a change set, new text, swapped contact details, first-line screening of requests. Word lists for the nine languages.
../raiju-map.php Where things are in the dashboard (see "Raiju map"); used by find_in_dashboard and the dashboard assistant.
media.php Image ingest (re-encoding, metadata removal), staging, stock photos, credits.
research.php Brave search, page extraction, the tool-less reading step, sources.
admin/api/agent.php The ai-agent-* actions.
js/ai-agent.js Cards, timeline, upload, resume; plugs into js/ai-copilot.js through registerAgent().

How a run works: the browser calls ai-agent-start, then ai-agent-step repeatedly (each step does one model turn or executes pending tool calls and is safe against parallel steps). State and transcript live on the server in content/ai-agent/runs/; the browser only sends ids and the user's own words. Slow tools end the step (web reads); image generation waits in waiting_jobs. Limits per round (turns, tool calls, failures, runtime) and a cost cap per run apply on top of the gateway's daily and monthly limits.

Tools (raijuAgentToolDefinitions()): read tools (get_site_overview, find_in_dashboard, get_page_capabilities, read_content, list_content, list_media, list_run_media), research (search_web, read_web_result, read_url), images (search_stock_images, import_stock_image, generate_image), stage tools (stage_set_field, stage_set_image, stage_add_list_item, stage_hide_list_item, stage_remove_list_item, stage_move_list_item, stage_add_section, stage_create_news, stage_create_event, stage_create_page, stage_revert_op) and the flow tools (validate_changeset, ask_user, present_changeset). Every tool has a JSON schema, a right and a handler; no tool takes a file path, runs code or writes outside the change set. A new tool needs: a definition with schema and right, a handler, an agent.step.<name> text in all admin/lang/*.json, an entry in TOOL_STEPS of js/ai-agent.js, and a test.

Security rules for agent work (read before touching includes/ai/agent/):

  • Safety check (safety.php) judges effects, not words. raijuAgentSafetyAssess() compares the replayed content with what is stored (before/after per file) and runs inside raijuAgentValidate(), which staging, finalize and apply all call. Never move a check into a tool handler or the prompt, never trust flags stored in an operation, and never let a new operation type skip it: the amounts (removed, hidden, cleared parts; lost text; created and touched targets; menu changes) are summed over the whole change set, so splitting a deletion into many small operations does not help. block findings are errors (no ready, no present, no apply, no override for anyone); caution findings are warnings with a safety_ code, shown in the review, and ai-agent-apply needs confirmRisk=1 for them. New text is scanned only where it differs from the stored text. Add words to safety-patterns.php (folded text: lowercase, no accents), never inline; add a test case to tests/agent-safety-smoke.php for every new rule. The limits live in RAIJU_AGENT_SAFETY_LIMITS and are lowered, not raised. The patterns are a heuristic, not a moderation service; the amounts are the part that does not depend on wording. raijuAgentSafetyScreenRequest() refuses obvious requests before the model is called (agent start and replies, assistant chat); it is a first line, not the enforcement.

  • The classic Copilot uses the same word lists through raijuCopilotSafetyJudge() (raijuAgentSafetyJudgeTexts()): raijuCopilotValidateProposals() drops harmful suggestions before they are signed (the $report argument counts them, the endpoints answer with copilot.safety_blocked when nothing is left) and adds safetyNote to odd ones; raijuCopilotNormalizeCreateDraft() refuses harmful drafts and adds safetyNote. Only the text check applies there: every suggestion is one field, previewed and signed.

  • Every code in safety.php ($add('mass_removal', …)) needs agent.safety.{code} in all nine languages; agent.safety_refuse.{category} for refusals. Audit entries (agent-safety) carry the stage and codes or category, never text.

  • The model output and everything it reads (page content, search results, web pages, image metadata) is untrusted. Tool results are data, never instructions; the system prompt says so and the tools enforce it.

  • Web pages are never shown to the agent. The server fetches a result with raijuNetSafeFetch() (robots.txt, domain lists, no own host), extracts text with DOMDocument and gives it to a separate model call without tools that may only answer with a strict JSON schema. URLs, code, instructions and quotes that are not in the page are dropped; instruction-like pages get a warning on the source. The agent receives only that JSON, marked untrusted.

  • read_url reads a page whose address the user typed in this run (goal or replies), matched against the user's own messages; addresses the model found, built or took from a page are refused. It needs no search key, goes through the same safe fetch, robots.txt, domain lists, reading step and warnings, and counts towards the page-read limit.

  • External links in written content are allowed only when the user typed the address or a source of the run was actually fetched. The rule also covers links inside rich text.

  • Targets are keys checked by regex and the capability map (page:{key}, news:{id}, event:{id}); no paths, no __proto__-style keys, no structural fields such as visibility.passwordHash. Private pages enter the context only when the user names them, and their password hash never does.

  • New news, events and pages are created hidden/private; list items and sections start hidden. Publishing is a separate right and a separate click.

  • Images are validated (type, size, pixels), re-encoded without metadata and staged in content/ai-agent/staging/; only images that the applied package uses move into assets/images/, with credit and AI label. Uploaded SVG and GIF are refused. The AI label is read before re-encoding.

  • Every state change is audited (raijuAgentAudit()): ids, hashes and counts, never goal text, page content, fetched text or keys. Search queries are logged readably because they leave the server.

  • All actions are POST with CSRF except the read-only ai-agent-status, ai-agent-changeset and ai-agent-media (GET); runs, change sets and staged images belong to their owner (admins see all); rate limits apply per user.

  • content/ai-agent/ must stay non-public (it lives under content/, which .htaccess and the dashboard security check cover). Do not log it, do not link to it, and do not expose staging paths to the browser (the review shows thumbnails through ai-agent-media).

  • Test-only network redirection (RAIJU_AGENT_TEST_NET, testTransport, ...) works only under the CLI and the built-in server.

Site builders can restrict what the agent may do per page with content/ai-schema/ (see SKILLS.md → "ai-schema-override"). Overrides only restrict: they can lower limits, mark paths read-only, require fields and add hints; they cannot make something editable.

Verification for agent changes:

python3 tests/run-smoke.py            # includes all agent-*-smoke suites
python3 tests/agent-browser-check.py  # Chromium (Playwright): panel, review, apply, undo, settings
node --check js/ai-agent.js

The agent suites in tests/: agent-safety (PHP and HTTP), raiju-map, agent-net, agent-scan-mode, agent-gateway, agent-capabilities, agent-changeset, agent-preview, agent-tools, agent-loop, agent-media-ingest, agent-media, agent-research-extract, agent-research, agent-settings (PHP and HTTP), agent-rate-limit, agent-image-label, agent-api, agent-js and agent-security, plus the Copilot i18n check for the agent.* and ai.agent_* texts in all nine languages.

Admin UI Conventions

Recent dashboard work tightened several admin surfaces. Preserve these patterns when adding related UI:

  • Content editor sections: the left section jump menu is sticky while scrolling, supports search and type filtering, keeps long labels clamped, and greys out filtered/non-jumpable items. Keyboard section navigation should keep the active marker stable after programmatic scrolls.
  • Section editor cards: type badges should look like metadata, not primary action buttons. Insert controls between filtered-out sections should not leave large empty gaps.
  • Messages: the page is explicitly labelled as contact/form submissions. Header copy sits with the title, actions align as a toolbar, and the list can filter by form.
  • Settings forms: prefer clear native controls (select, checkboxes, buttons) over card-like pseudo-buttons when the interaction is selection. Compact editor rows should use normal label casing, aligned controls, and no horizontal overflow.
  • Media and branding previews: transparent images should be rendered on a visible checkerboard/transparency surface in dark mode.
  • Dashboard stats: stat labels align at the top of their cards and numbers align at the bottom for quick scanning.
  • AI dashboard panel: hide disabled AI tools. If AI services are disabled or unconfigured, show only a compact dismissible notice where appropriate.

Dashboard design system

The dashboard is built from one token set and five shared patterns. Keep to them when adding admin UI:

  • Tokens live in css/raiju-admin-tokens.css, prefixed --rj-*. Spacing (--rj-space-1 … 12, with half steps like --rj-space-2-5), a six-step type scale (--rj-text-xs … 2xl), one control height for inputs, selects and buttons (--rj-control-h, --rj-control-h-sm), radii and shell geometry (--rj-sidebar-w, --rj-topbar-h, --rj-content-max). Write new admin CSS with these tokens, never with pixel values.
  • Everything scales with the root font size. admin/style.css sets html { font-size: clamp(14px, …, 18px) }, and --rj-d (density, from the viewport or the user's "Dichte" setting via data-density on <html>) multiplies spacing and control heights. Do not reintroduce fixed widths for the sidebar, topbar or container.
  • One base colour. theme.primaryColor is the base; the stylesheet derives primary, hover, tints, the accent (--rj-accent-base, overridable through theme.accentColor) and, per preset, the surfaces in OKLCH relative colour syntax inside @supports (color: oklch(from red l c h)). Dark mode lifts the base to at least l 0.72; nothing stores a separate dark colour any more (darkPrimaryColor / darkAccentColor are accepted but ignored).
  • Semantic colours. theme.dangerColor, theme.warningColor and theme.successColor (empty = auto) reach the stylesheet as --rj-danger-base, --rj-warning-base, --rj-success-base; the tokens file derives hover, subtle and text shades from them in OKLCH, light and dark. Success follows the primary colour unless set. Console's and Pop's preset defaults set all three along with the base colour.
  • Presets (theme.preset: soft default, classic, paper, studio, console, pop) set surfaces, borders, radii, control heights and the sidebar/topbar tokens and bring a font (Soft Inter, Classic Public Sans, Paper Atkinson Hyperlegible, Studio IBM Plex Sans, Console Geist Mono, Pop Atkinson Hyperlegible) (--rj-sidebar-*, --rj-topbar-*). Admin CSS reads those tokens and never hard-codes chrome colours; the few per-preset component rules (table heads, button surfaces, sidebar item geometry) live in the "PRESET REFINEMENTS" block at the end of admin/style.css. Two presets follow published references: Soft uses the shadcn/ui measures (zinc greys, 36 px / 32 px controls, 8 px cards / 6 px controls, 40 px table heads in muted text) on a faintly tinted shell; Studio follows the Vercel dashboard / Geist (pure neutral greys, #eaeaea hairlines, black buttons, white on black in dark mode, 40 px / 32 px controls, 6 px radius). In Studio the base colour is an accent only (focus ring, success, badges), so --rj-primary is near-black there and must not be assumed to carry the brand hue. Console is the ops-console look: Geist Mono as the UI face (theme.font: geist-mono), a smaller type scale, caps with tracking on labels, heads, navigation and buttons, square corners, dashed group dividers, and a crosshair on selected elements (four corner brackets drawn by one ::after with eight gradient strokes; see --rj-xh-* in the Console block). The base colour keeps its full chroma there. Pop is the playful one: sidebar and topbar in a deep slate derived from the base hue (--rj-sidebar-bg), the sidebar running the gradient token --rj-sidebar-gradient (built from --rj-sidebar-bg, so a custom sidebar colour keeps the gradient); a light stage with white cards and soft shadows; legends, panel titles, stat and status labels as pills (--rj-pop-pill for the colour, --rj-pop-pill-text for the text on it, stat tiles cycling base/danger/warning/success/accent through nth-child); second-level labels as tinted pills; round navigation and swatches. Its preset defaults set the four palette colours like Console does. <html> carries data-theme-preset, data-density and data-font; admin/dashboard.php emits --rj-base and optional sidebar overrides inline, applyTheme() in admin/dashboard/scripts/settings.php mirrors that live.
  • Collapsible sidebar. <html data-sidebar="collapsed"> shrinks the sidebar to its icons (--rj-sidebar-w 3.5rem, labels hidden, badges in the corner, tooltips from the label text). The state is a per-browser preference in localStorage (raijuSidebarCollapsed), set by a head script before first paint and toggled by #sidebarCollapseBtn at the sidebar's foot; the mobile drawer below 769px ignores it.
  • Fonts are local. theme.font is system (default, no file), inter, plex (IBM Plex Sans), atkinson (Atkinson Hyperlegible Next), public (Public Sans) or geist-mono; a stored geist reads as plex. theme.fontSize (small, default, large, data-font-size on <html>) scales the type steps only (--rj-font-scale), never spacing or control heights; faces are declared in admin/fonts.css from admin/fonts/. Never link an external font service in the admin.
  • Feature stylesheets (css/ai-copilot.css, css/image-manager.css) are loaded on the public site too, so they read tokens with fallbacks: var(--rj-space-3, 12px), var(--rj-text, #172033). Keep that form there. In dark mode the primary colour is light, so anything on it uses --rj-text-on-primary (dark in dark mode), never a literal white.
  • Patterns: every tab starts with .page-list-header (title + description left, .page-list-header-actions right, one .btn-primary); the topbar shows the site name and a breadcrumb via rjSetTopbarTitle(); cards have at most two levels (fieldsets inside a settings card are groups with a divider, not a second box); settings forms use label-left rows from 1100px and end in a sticky .settings-actions bar; lists share .page-list-table, hover/focus row actions and rjRenderEmptyRow(); dates go through rjFormatDate().

Select carets

Load css/rj-select.css for admin/editor controls. Enhanced dropdowns and select[data-native-select] share a 14px caret inset through --rj-select-caret-inset. The native attribute retains the browser picker and keyboard behavior, while CSS draws the closed-state caret with reserved text space. Do not restore appearance: auto or replace its background shorthand in component styles. Multiple/size listboxes are excluded; forced-colors mode restores the system arrow. RTL fields move the caret to the left automatically. Use --rj-select-caret-image only when a theme needs a different caret color.

Admin CSS is cache-busted from admin/dashboard.php with filemtime() so layout changes in admin/style.css are visible after reload.

Multilingual Admin Editor Fields

Admin/editor dialogs can group language variants of the same field into tabs. This is a Core UI feature for editor modals and structured admin editors, not for public visitor forms.

Use either a language-object field with localized: true:

[
    'label' => 'Event',
    'fields' => [
        ['key' => 'title', 'type' => 'input', 'label' => 'Title', 'localized' => true],
        ['key' => 'description', 'type' => 'textarea', 'label' => 'Description', 'localized' => true],
        ['key' => 'url', 'type' => 'url', 'label' => 'URL'],
    ],
]

or explicit language-suffixed paths such as title.de, title.en, description.de, and description.en. Raiju reads language codes from $SITE_LANGUAGES; one-language sites render the fields normally without tabs. Fields that are not language-specific remain outside the tabs. The tab UI is a rendering layer only, so saving keeps the same JSON paths and data shape.

When the AI module is enabled, multilingual editor tabs show a translation button for the active source language. The generated translations are written only into the still-open dialog fields and are persisted only if the editor uses the normal Save action.

Accessibility Best Practices

Raiju Core provides the baseline accessibility plumbing for public pages and admin UI: a skip link, main-content target handling, landmark-friendly header and footer navigation, visible focus states, reduced-motion CSS, labelled toolbar controls, modal dialog semantics, live regions for toast/status messages, and SEO health checks for missing image alt text.

When creating or adapting site templates, preserve that foundation:

  • Use one primary <main> element per page. Standard JSON pages get id="main-content" automatically; custom templates should add that id or include includes/footer.php so the fallback can attach it to the first <main>.
  • Keep heading hierarchy semantic. Use one visible H1 for the page topic, then H2/H3 sections in order. Do not choose heading levels only for visual size.
  • Provide meaningful alt text for informative images. Use an empty alt (alt="") only for decorative images. The page SEO health indicator reports missing image alt text.
  • Use native elements: <button> for actions, <a href> for navigation, and <label for> for form controls. Avoid click handlers on plain <div> or <span> elements.
  • Keep focus visible. Do not remove outline or box-shadow focus states unless a replacement with comparable contrast is added.
  • Respect prefers-reduced-motion. Page-specific animation CSS should include a reduced-motion path or rely on the Core global reduction rules.
  • Connect form errors and hints to their fields with nearby text and, when custom JavaScript is involved, aria-describedby plus an aria-live status area.
  • Test new public templates with keyboard-only navigation before launch: Tab, Shift+Tab, Enter/Space, Escape for overlays, and visible focus order.

Frontend Access Controls

Raiju's access controls are implemented centrally in includes/access-guard.php. They are loaded by includes/header.php, so custom templates must set $contentPage before including the header if page-level privacy should apply. Standard JSON pages already do this through includes/page.php.

Maintenance Mode

Editors configure maintenance mode in Dashboard -> Settings -> Access. The settings are stored under access.maintenance in content/settings.json:

{
  "access": {
    "maintenance": {
      "enabled": true,
      "mode": "maintenance",
      "title": "Maintenance",
      "text": "We will be back online shortly.",
      "until": "2026-05-20T12:00",
      "showCountdown": true,
      "brandAsset": "logo",
      "image": "/assets/images/maintenance.jpg",
      "imageLayout": "left",
      "overlayColor": "#ffffff",
      "overlayOpacity": 88,
      "bypassParam": "preview",
      "bypassKeyHash": "$2y$...",
      "bypassLinks": [
        { "param": "preview", "key": "k3Q9..." },
        { "param": "preview", "key": "agency-2026" }
      ]
    }
  }
}

bypassLinks holds any number of URL parameter / key pairs, stored as entered so the dashboard can show and hand out the finished links; a request carrying one of them (/?preview=k3Q9...) passes the guard and the bypass sticks to the session. Deleting a row revokes that link. bypassKeyHash is the older single hashed key; it keeps working until the access form is saved once, which replaces it with the visible links.

Valid mode values are maintenance, offline, and launch. The guard blocks public frontend pages with a standalone 503 response and sets noindex for the lock page. Static assets, admin routes, API routes, robots.txt, and sitemap.xml are excluded. Logged-in admins/editors bypass the lock automatically.

Optional visual fields are brandAsset (none, favicon, or logo), image (an /assets/images/... path), imageLayout (none, background, left, or right), overlayColor (a hex color used only for background images), and overlayOpacity (0-100 percent alpha for that overlay). Legacy split values are treated as left.

The preview bypass is session-based. If bypassParam is preview, a visitor can open /?preview=<secret> once; the secret is checked against bypassKeyHash and the bypass then lasts for the browser session. Do not write plaintext bypass keys to JSON; use the admin settings form or hash secrets with password_hash().

Private Pages

Page-level password protection lives in the page JSON visibility object:

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

Use the Content Editor's Access card to switch a page to private and set the password. The API accepts a transient visibility.password value, hashes it, and stores only passwordHash. Direct JSON edits should use passwordHash; never commit plaintext page passwords.

Private pages return a standalone password form until the correct password is submitted. Successful access is stored in the session per page. Logged-in admins/editors bypass page passwords. Password-protected responses use noindex, nofollow so protected content is not advertised to crawlers.

How to Create a Standard Page

The fastest way is with the scaffolding tool:

php cli/make.php --slug=about --lang=en --title="About Us"

This creates content/pages/en_about.json with a heading and text section. The router + front controller (includes/page.php) will automatically serve /about or /en/about using renderAllSections(). No PHP template file needed.

Nested Page Paths

Use slash-separated slugs when an information architecture or an existing SEO URL must be preserved:

php cli/make.php --slug=products/vitamin-d --lang=en --title="Vitamin D"

The public path remains /products/vitamin-d (or /{lang}/products/vitamin-d for a non-default language). Content remains in a flat directory: Raiju reserves __ as the path separator in page identifiers, so the example is stored as content/pages/en_products__vitamin-d.json with "path": "products/vitamin-d". Never expose the encoded identifier in links, Canonicals, hreflang URLs, redirects, or sitemap entries. Custom templates use nested directories, for example en/products/vitamin-d.php.

The page appears in header navigation automatically via auto-discovery. To control which menus a page appears in, use --nav=header,footer-pages or set "nav": ["header", "footer-pages"] in the JSON. Use --nav=none to hide from all menus.

To create the JSON manually instead:

{
  "page": "en_about",
  "lang": "en",
  "title": "About Us",
  "description": "Learn more about us.",
  "sections": [
    { "id": "s1", "type": "heading", "text": "About Us", "level": "h1" },
    { "id": "s2", "type": "text", "title": "", "content": "<p>Our story...</p>" }
  ]
}

Staging sites are blocked for search engines with content/settings.json → seo.noindexSite: true (Settings → Branding → Search engines). It sets Disallow: / in robots.txt, noindex, nofollow in the robots meta tag and in X-Robots-Tag, and empties sitemap.xml. Set it back to false and seo.siteUrl to the live address when the site goes live. Do not block staging with a static robots.txt in the web root (it replaces Raiju's own) or with custom headers: the dashboard's "Search engines" check (system status) warns about every such block on a public host, but cannot tell a forgotten one from an intended one.

SEO and social sharing metadata live in the optional top-level seo object. includes/header.php renders these fields as the visitor-facing meta tags, Open Graph tags, Twitter card tags, canonical URL, robots meta, and JSON-LD. Open Graph images must be JPG or PNG files under /assets/images/; do not use WebP for social previews because common sharing crawlers do not reliably render it. If no page-specific seo.ogImage is set, Raiju falls back to content/settings.json → seo.defaultOgImage, then to the first JPG/PNG image found in the page content.

{
  "seo": {
    "title": "SEO title",
    "description": "Meta description",
    "answerSummary": "Short answer for answer engines.",
    "canonical": "",
    "robots": "index, follow",
    "ogTitle": "Open Graph title",
    "ogDescription": "Open Graph description",
    "ogImage": "/assets/images/og-image.jpg",
    "sitemap": true
  }
}

To control navigation order, labels, or dropdown grouping, edit includes/nav-config.php ($PAGE_MAPPING and $NAV_ITEMS). Pages listed there take priority; auto-discovered pages are appended after. Nav items support children for dropdown menus and an optional 'nav' key to control which locations they appear in.

How to Create a Custom Layout Page

When you need full control over the HTML structure (hero sections, grids, custom components):

php cli/make.php --slug=your-page --lang=en --type=custom --title="My Page"

This creates both en/your-page.php and content/pages/en_your-page.json with hero + content boilerplate. Edit the PHP template to customize the layout.

To create manually instead:

  1. Create {lang}/your-page.php:
<?php
$pageTitle = 'My Page';
$pageDescription = 'Description for SEO.';
$currentLang = 'en';
$currentPage = 'your-page';
$contentPage = 'en_your-page';
$basePath = '../';

include '../includes/header.php';
include '../includes/content-loader.php';
$_p = $contentPage;
?>
    <main class="main-content">
        <div class="content-inner">
            <h1><?php echo editableText($_p, 'hero.title', 'Default Title'); ?></h1>
            <p><?php echo editableText($_p, 'hero.subtitle', 'Default subtitle'); ?></p>
            <?php echo renderFeatureGrid($_p); ?>
        </div>
    </main>
<?php include '../includes/footer.php'; ?>
  1. Create the matching content/pages/en_your-page.json with the same dot-notation keys.

Block Types

These types are available in sections[] arrays for standard pages:

Type Category Key Fields
text content title, content (HTML), titleTag, style
heading content text, level (h1-h6), subtitle
quote content text, attribution, style (default/large)
list content title, style (bullet/numbered), content (HTML)
image media src, alt, caption, width (full/medium/small)
card cards title, content, image
youtube media title, videoId
soundcloud media title, trackId
audio media title, src
divider layout (no fields)
spacer layout height (sm/md/lg/xl)

How Inline Editing Works

The frontend admin bar lists persisted backups of the current content page when available, in both browse and edit mode. The dropdown uses local timestamps; Apply opens a confirmation and warns about unsaved page/shared edits. Restoring checks the loaded page revision, backs up the saved state first, reloads the server-rendered page, and resumes the previous edit mode. Page backups contain content and image references; they do not recover deleted media files. News-post pages do not show this page-only control. Keep this separate from session Undo.

While logged in and browsing with editing disabled, two clicks on non-interactive page areas open a one-time reminder with an Activate visual editor button. Links, forms, controls, open dialogs and active edit mode are excluded. Dismissing the reminder suppresses it until the next page load; it never saves content.

This is what Raiju does. You take any existing HTML page — hand-built, designed in Figma, or vibe-coded with AI — and make it editable by swapping hardcoded content for Raiju's PHP helper functions. Each function injects data-* attributes that the inline editor JavaScript discovers. Logged-in admins click to edit text, swap images, and change links directly on the page. Content gets stored in JSON files. Visitors see clean HTML with zero editor markup. No database, no migration, no complex setup — just a PHP include and a few function calls turn a static page into a CMS.

Existing-site rule: preserve, do not rebuild. When converting an existing site, treat the source HTML/CSS/JS/assets as the visual source of truth. Copy local stylesheets, scripts, media, fonts, class names, layout wrappers, animation hooks, and responsive rules before adding Raiju fields. Do not recreate the design with new tokens, new grids, new cards, simplified markup, or approximate CSS. Raiju conversion is a surgical content edit: keep the rendered page visually identical, then make its text, images, links, and repeaters editable.

The pattern is always the same:

  1. Wrap your content in an editable function call instead of hardcoding text
  2. The function outputs data-* attributes for admins (e.g. data-page, data-field, data-editable-image)
  3. Content is stored in JSON — the function reads from and writes to content/pages/{lang}_{slug}.json
  4. Visitors see plain HTML — no attributes, no editor UI

For a pre-existing designed page, these steps happen inside the original structure. Preserve the same element hierarchy wherever possible. If an editable helper introduces a wrapper that affects CSS selectors or spacing, add the smallest site-owned compatibility rule in css/website.css or css/page-{slug}.css; do not rewrite the component.

Visual-Preserving Migration Checklist

Use this checklist before and after converting any existing site:

  1. Start the original page locally and capture reference screenshots for the hero, every section, footer, key hover/open states, scroll-pinned or animated areas, and mobile breakpoints.
  2. Copy original local CSS, JS, assets, fonts, favicon files, and media paths into the Raiju project. Keep source class names and data attributes that scripts rely on.
  3. Convert by replacing hardcoded text, images, links, and repeatable data with editable helpers. Do not change layout wrappers, grid structure, animation containers, or CSS selectors unless the helper API makes it unavoidable.
  4. Keep original public styles site-owned: use css/website.css for shared site CSS and css/page-{slug}.css for page-specific CSS. Raiju core files (css/style.css, css/components.css, core JS) should not absorb customer design rules.
  5. Run the converted page locally and capture the same screenshots. Compare section by section against the reference. Check computed styles for fragile details such as header transparency, backdrop filters, font weights, gradients, border radii, image crop/object-position, padding, card sizes, z-index, and scroll animation backgrounds.
  6. Treat visual drift as a bug. Only accept a design difference when the user explicitly requested a redesign or approved the tradeoff.

Anti-patterns for existing-site migrations:

  • Rebuilding the stylesheet from memory or screenshots instead of copying it.
  • Replacing original page CSS with Raiju theme variables unless it is a deliberate, reviewed refactor.
  • Changing cards, wrappers, or layout grids to "fit" an editable abstraction.
  • Simplifying scroll/animation sections, canvas/WebGL scenes, or hover states.
  • Guessing font weights, spacing, gradients, image crops, or breakpoint rules.

Example: Before and After

Static HTML (not editable):

<h1>Welcome to My Site</h1>
<p>We build great things.</p>
<a href="/contact" class="btn">Contact Us</a>
<img src="assets/images/hero.jpg" alt="Hero">

Raiju-editable version:

<h1><?php echo editableText($_p, 'hero.title', 'Welcome to My Site'); ?></h1>
<p><?php echo editableText($_p, 'hero.subtitle', 'We build great things.'); ?></p>
<?php echo editableLink($_p, 'hero.cta', 'Contact Us', '/contact', 'btn'); ?>
<?php echo editableImage($_p, 'hero.image', 'assets/images/hero.jpg', 'Hero'); ?>

What the admin sees (HTML output):

<h1><span class="editable-field" data-page="en_home" data-field="hero.title">Welcome to My Site</span></h1>
<p><span class="editable-field" data-page="en_home" data-field="hero.subtitle">We build great things.</span></p>
<a href="/contact" class="btn" data-editable-link data-page="en_home" data-field="hero.cta">Contact Us</a>
<img src="assets/images/hero.jpg" alt="Hero" data-editable-image data-page="en_home" data-field="hero.image">

What visitors see (clean HTML):

<h1>Welcome to My Site</h1>
<p>We build great things.</p>
<a href="/contact" class="btn">Contact Us</a>
<img src="assets/images/hero.jpg" alt="Hero">

Data Attributes Reference

Attribute On Purpose
class="editable-field" text/html JS discovers text fields via this class
class="editable-field-html" html Distinguishes rich-text from plain-text fields
data-page all Which JSON file to read/write (e.g. en_home)
data-field all Dot-notation key in JSON (e.g. hero.title)
data-editable-link links JS discovers editable links via this attribute
data-editable-image images JS discovers editable images via this attribute
data-editable-group component wrappers Opens one modal for several custom-layout fields
data-group grouped component wrappers Dot path used as the base for grouped fields
data-group-schema grouped component wrappers JSON schema describing the grouped editor fields
data-editable-list list containers Marks a repeating-items container
data-list-key list containers/items Dot path to items object
data-list-index list items Numeric index (0, 1, 2...)
data-hidden any Hides element from visitors when "true"
data-allow-field-hide text/link fields Opts a specific field into field-level hide controls

JSON Is the Source of Truth

The JSON file (content/pages/{lang}_{slug}.json) is the single source of truth for all editable content. Both the Visual Editor (frontend) and the Content Editor (backend dashboard) read from it.

Auto-Write: How Missing Fields Get Populated

When an admin browses a page, every editableText(), editableHtml(), editableImage(), and editableLink() call checks whether its key exists in the JSON file. If the key is missing, Raiju automatically writes the fallback value to the JSON file. This means:

  • An agent (or human) can focus on the PHP template — just use editableText($_p, 'hero.title', 'Welcome to My Site') with a good fallback value.
  • On first admin page view, any missing keys are written to the JSON using those fallback values.
  • The Content Editor will then show all fields, because the JSON is now populated.
  • A toast notification tells the admin how many fields were auto-generated (e.g. "Auto-wrote 4 missing fields to en_about.json").

This is the intended workflow. Auto-write exists so you don't have to maintain content in two places.

What This Means for Agents

  1. Always provide meaningful fallback values — they become the initial content in the JSON. Write fallbacks as if they were the real content, not just "Default Title".
  2. You don't need to pre-populate the JSON file for custom layout pages. The PHP template + fallbacks are enough. The JSON will be populated on first admin render.
  3. For standard pages using sections[], you still create the JSON with the sections array, because renderAllSections() iterates over it — there are no PHP fallbacks for section structure.
  4. For editable lists, the list structure must exist in JSON (since editableListItems() returns [] for missing keys and does not auto-write). Individual item fields within an existing list do auto-write.

Example: Custom Layout Page

The agent writes the PHP template with good fallbacks:

<h1><?php echo editableText($_p, 'hero.title', 'Welcome to My Site'); ?></h1>
<p><?php echo editableText($_p, 'hero.subtitle', 'We build great things.'); ?></p>
<?php echo editableLink($_p, 'hero.cta', 'Get Started', '/pricing', 'btn'); ?>

The JSON file can start minimal (just metadata):

{
  "page": "en_about",
  "lang": "en",
  "title": "About Us",
  "description": "Learn more about us."
}

After the first admin page view, the JSON is automatically populated:

{
  "page": "en_about",
  "lang": "en",
  "title": "About Us",
  "description": "Learn more about us.",
  "hero": {
    "title": "Welcome to My Site",
    "subtitle": "We build great things.",
    "cta": { "text": "Get Started", "href": "/pricing" }
  },
  "lastModified": "2026-04-04T12:00:00+00:00"
}

When You Must Pre-Populate JSON

  • Standard pages with sections[] — renderAllSections() needs the sections array to exist.
  • Editable lists — editableListItems() returns [] for missing keys; the list structure (with at least one item) must be in the JSON.
  • Any field that should have content different from the PHP fallback — the fallback is just the initial value; edit the JSON directly if you want different content.

Template API (content-loader.php)

Editable Fields

editableText($page, $fieldKey, $default = '')
// Plain text. Admin: <span class="editable-field" data-page="..." data-field="...">
// Visitor: escaped text string.

editableHtml($page, $fieldKey, $default = '')
// Rich HTML. Admin: <div class="editable-field editable-field-html" ...>
// Visitor: sanitized HTML.

editableImage($page, $fieldKey, $defaultSrc = '', $defaultAlt = '', $class = '')
// Image. Admin: <img data-editable-image data-page="..." data-field="...">
// Visitor: plain <img>. JSON stores {src, alt}.

editableLink($page, $fieldKey, $defaultText = '', $defaultHref = '#', $class = '', $attrs = '')
// Link. Admin: <a data-editable-link data-page="..." data-field="...">
// Visitor: plain <a>. JSON stores {text, href}.

editableIcon($page, $fieldKey, $default = '', $class = '')
// Icon token. Stores a plain text icon key and renders SVG from the icon set.

contentBindAttrs($page, $fieldKey, $type = 'text')
// Not editable itself: binds an element that repeats a field shown elsewhere
// (image preview, table-of-contents label) so the visual editor refreshes it live.
// Admin: data-rj-bind="image|text|html" data-rj-bind-page data-rj-bind-field.
// Visitor: empty string. Images update their src, text/html their content.

raijuAiLabelHtml($src, $lang = null)
// AI disclosure label (EU AI Act, Art. 50) for an image source, or '' when the file
// is not labelled. editableImage(), editableImageSplit() and the core blocks add it
// automatically; call it yourself only next to plain <img> output.

Image Uploads and Web Versions

The media library accepts images up to media.maxUploadMb (20 MB by default; Settings → Modules → Media library). PHP's own upload_max_filesize / post_max_size cap every upload first: list-media returns both in limits, the library shows the lower one and checks before sending, and the API answers an oversized request with the server limit (admin/api.php handles the case where PHP dropped the whole POST). A JPEG, PNG or WebP that is larger than 400 KB or wider than media.webMaxSide (2000 px) is re-encoded on upload by includes/media-web-version.php: scaled to that side, saved as <name>.webp with media.webQuality, and the upload moves to assets/images-originals/<same relative name with its own extension>. listMediaFiles() attaches it as original (name, public path, size, dimensions) and flags files above 2 MB as large; the library shows badges, and the download action hands out the original. raijuMediaOriginalRelocate() / raijuMediaOriginalForget() keep originals in step with rename, move, trash, restore and permanent deletion (assets/images-originals-trash/). GIF and SVG are never converted; media.webVersion: false stores every upload as it is. The system status lists the PHP limits, memory_limit and the web-version state; re-encoding raises memory_limit for the request when the server allows it.

AI Disclosure Labels

Images created or modified with AI must be disclosed (EU AI Act, Art. 50). Editors set the label per file in the media library ("AI label": none, AI-generated, AI-modified); uploads with embedded provenance (IPTC digital source type in XMP or C2PA) are labelled automatically. Labels are stored in content/media-meta.json and follow the file when it is renamed, moved, trashed or restored.

Raiju renders the label right after the image: <span class="rj-ai-label"> with English artwork from css/ai-labels/ ("AI generated", "AI modified") and an alt text in the page language. German artwork ("KI generiert") is optional: with Settings → Language → AI labels on images = page language (settings.json → general.aiLabelArtwork = "page"), German pages use it; raijuAiLabelArtworkMode() reads the choice. css/ai-labels/en-ai.svg, a round "AI" badge, ships unused for later purposes such as AI-generated news or blog texts. The label is absolutely positioned in the image's parent, so place labelled images in a frame (a wrapper that has the image's size) or use raijuAiLabelAttach($imgHtml, $src, true), which wraps image and label in .rj-ai-media. Default styles in css/components.css: bottom right, hidden at rest, shown after 600 ms hover; js/ai-labels.js reveals labels on touch devices once the visitor lingers. Screen readers always get the alt text. Adapt with --rj-ai-label-height, --rj-ai-label-inset, --rj-ai-label-rest (e.g. 0.25 for a subtle label at rest) and --rj-ai-label-filter (invert(1) for a light label).

In the visual editor, js/inline-editor.js keeps labels in sync when an image is swapped, on undo/redo and when a label changes in the media library. It reads window.RJ_AI_LABELS (from raijuAiLabelEditorConfig()), which the core footer provides; sites with their own includes/footer.php add that line to their editor script block. Custom templates that repeat an image via contentBindAttrs() should render raijuAiLabelHtml($src) right after it, as the editor does the same.

Icon keys are resolved through content/settings/iconset.json first. That file is site-owned and upgrade-safe. Raiju also ships includes/default-iconset.json as the core fallback, and unknown icon keys render the default icon. Icon definitions may include an optional viewBox; if omitted, Raiju uses 0 0 24 24. Prefer renderIconSvg($iconKey) when rendering complete icons so custom viewBox values are preserved. getIconSvg($iconKey) remains available for legacy code that needs only inner SVG markup. The dashboard Icon Manager can import selected icons from a whitelisted Iconify set; imports are copied into the site-owned iconset JSON, so visitor pages do not depend on Iconify at runtime.

Editable Groups

Use editable groups for custom-layout components whose fields belong together: feature cards, team cards, icon + title + text blocks, CTA cards, image + caption and copyright blocks, contact cards, and similar components.

Groups are explicit. Do not infer them from DOM proximity. If an h3 and a paragraph should be edited together, wrap them in an editable group or add editableGroupAttrs() to their component wrapper.

<article class="feature-card"<?php echo editableGroupAttrs($_p, "features.items.$i", [
    'label' => 'Feature',
    'fields' => [
        ['key' => 'icon', 'type' => 'icon', 'label' => 'Icon'],
        ['key' => 'title', 'type' => 'text', 'label' => 'Title'],
        ['key' => 'desc', 'type' => 'textarea', 'label' => 'Description'],
        ['key' => 'link', 'type' => 'link', 'label' => 'Link'],
    ],
]); ?>>
    <?php echo editableIcon($_p, "features.items.$i.icon", 'heart-pulse', 'feature-card__icon'); ?>
    <h3><?php echo editableText($_p, "features.items.$i.title", 'Feature title'); ?></h3>
    <p><?php echo editableText($_p, "features.items.$i.desc", 'Feature description'); ?></p>
    <?php echo editableLink($_p, "features.items.$i.link", 'Learn more', '/contact', 'feature-card__link'); ?>
</article>

Available group field types: text/input, textarea, html/wysiwyg, select, checkbox, number, url, image, link, and icon. Field keys are relative to the group key unless a field defines an absolute path. For icon fields, all icons from the icon set are selectable by default. Add an icons array to the field schema to restrict a picker to a contextual subset. Raiju suppresses group modals for single plain-text fields; those list items stay inline-only. A group modal appears for multiple related fields or special fields such as image, link, icon, select, or checkbox.

Use field types deliberately. Image-like keys (image, imageSrc, src, photo, portrait, logo, avatar, cover, thumbnail) should use type: image so the Media Manager is available. Link-like keys (link, href, url, cta, button) should use type: link so editors get page selection, manual URL entry, target, and download controls. Short labels such as title, heading, meta, and label are usually type: text; longer fields such as text, description, intro, body, and content are usually type: textarea.

For templates where adding attributes to the existing wrapper is awkward, use the admin-only wrapper helpers:

<?php editableGroupStart($_p, 'hero.media', [
    'label' => 'Image',
    'fields' => [
        ['key' => 'image', 'type' => 'image', 'label' => 'Image'],
        ['key' => 'caption', 'type' => 'text', 'label' => 'Caption'],
        ['key' => 'copyright', 'type' => 'text', 'label' => 'Copyright'],
    ],
]); ?>
    <?php echo editableImage($_p, 'hero.media.image', 'assets/images/hero.jpg', 'Hero'); ?>
    <figcaption><?php echo editableText($_p, 'hero.media.caption', 'Caption'); ?></figcaption>
<?php editableGroupEnd(); ?>

Editable Lists

For repeating content (feature cards, team members, etc.):

editableListAttrs($page, $listKey, $defaults = [])
// Returns data-editable-list + data-list-page + data-list-key attributes.
// Add to the wrapper element: <div <?php echo editableListAttrs(...); ?>>

editableListItemAttrs($page, $listKey, $index)
// Returns data-list-page + data-list-key + data-list-index attributes.
// Add to each item: <div <?php echo editableListItemAttrs(...); ?>>

editableListGroupItemAttrs($page, $listKey, $index, $schema = [], $defaults = [], $label = 'Item')
// Returns list-item attributes plus data-editable-group when the schema/defaults
// describe a structured card-like item. If $schema is empty, Raiju infers one
// from $defaults only for multi-field or special-field items. Single-field text
// lists remain inline-only.

editableListItems($page, $listKey)
// Returns array of items from JSON. Filters hidden items for visitors.

editableTextList($page, $listKey, $defaults = ['content' => ''])
// Renders a list of editable HTML paragraphs with add/remove/reorder controls.
// Each item is a single editableHtml() field. Admins get inline editing with
// a floating toolbar (Bold, Italic, Link, Clean). Visitors see clean HTML.
// JSON stores: {"0": {"content": "..."}, "1": {"content": "..."}}

List usage pattern:

<div class="feature-grid" <?php echo editableListAttrs($_p, 'features.items', ['title' => 'New', 'desc' => '']); ?>>
    <?php foreach (editableListItems($_p, 'features.items') as $i => $item): ?>
        <div class="feature-card" <?php echo editableListGroupItemAttrs($_p, 'features.items', $i, [
            'label' => 'Feature',
            'fields' => [
                ['key' => 'title', 'type' => 'text', 'label' => 'Title'],
                ['key' => 'desc', 'type' => 'textarea', 'label' => 'Description'],
            ],
        ]); ?>>
            <h3><?php echo editableText($_p, "features.items.$i.title", 'Feature'); ?></h3>
            <p><?php echo editableText($_p, "features.items.$i.desc", 'Description'); ?></p>
        </div>
    <?php endforeach; ?>
</div>

For a simple one-field list, keep the item inline-only:

<div class="notes" <?php echo editableListAttrs($_p, 'notes.items', ['text' => '']); ?>>
    <?php foreach (editableListItems($_p, 'notes.items') as $i => $item): ?>
        <p <?php echo editableListItemAttrs($_p, 'notes.items', $i); ?>>
            <?php echo editableText($_p, "notes.items.$i.text", 'Note'); ?>
        </p>
    <?php endforeach; ?>
</div>

Paragraph list usage pattern:

<div class="about-text">
    <?php echo editableTextList($_p, 'about.paragraphs'); ?>
</div>
{
  "about": {
    "paragraphs": {
      "0": { "content": "First paragraph text..." },
      "1": { "content": "Second paragraph with <strong>formatting</strong>..." }
    }
  }
}

Render Components

renderAllSections($page, $staggerCards = false)     // All sections from JSON
renderFeatureGrid($page)         // features.items — icon + title + desc cards
renderPricingTable($page)        // pricing.plans — pricing cards with CTA
renderFaqAccordion($page)        // faq.entries — collapsible Q&A
renderTeamGrid($page)            // team.members — avatar + name + role + bio
renderGallery($page)             // gallery.images — image grid with lightbox
renderTimeline($page)            // timeline.entries — changelog/roadmap
renderStats($page)               // stats.items — key figures
renderTestimonials($page)        // testimonials.items — quote cards
renderComparisonTable($page)     // comparison.rows — feature comparison table
renderNewsList($limit, $lang)    // News posts from content/news/
renderBreadcrumb($page, $basePath) // Breadcrumb trail from JSON "breadcrumb" field

Content Helpers

loadContent($page)               // Returns full JSON array for a page
getContentValue($page, $key)     // Returns a single top-level value
loadNewsPosts($limit, $lang)     // Returns sorted news post array

Theming

Edit 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(...);
    --color-primary-btn-hover: radial-gradient(...);
    --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-border: rgba(0, 0, 0, 0.08);

    /* Dark theme */
    --color-bg-dark: #0c0c0c;
    --color-bg-dark-elevated: #141414;
    --color-bg-dark-card: #1c1c1c;
    --color-text-on-dark: #e5e5e5;
    --color-text-on-dark-muted: #a3a3a3;

    /* Gradients & glass */
    --gradient-brand: linear-gradient(135deg, ...);
    --gradient-brand-text: linear-gradient(90deg, ...);
    --glass-bg: rgba(255, 255, 255, 0.03);

    /* Typography */
    --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;

    /* Layout */
    --header-height: 80px;
    --container-max: 1400px;
    --container-narrow: 800px;
}

Dark theme is toggled via data-theme="dark" on <html>. Preference stored in localStorage key site-theme.

Custom fonts: place files in assets/fonts/, create css/fonts.css — includes/header.php loads it automatically if the file exists.

Best practice — overrides go in css/website.css, not css/style.css. style.css is Raiju-Core and gets overwritten on upgrade. Override theme variables in a :root block inside css/website.css (loaded after Core, so site values win on conflict). A pre-filled template with every overridable property is in examples/css/website.css.template — copy it to css/website.css and uncomment what you want to change.

Site Settings (content/settings.json)

{
    "favicon": "/assets/images/favicon.svg",
    "favicon_png": "/assets/images/favicon.png",
    "branding": {
        "logo": "",
        "name": "My Site",
        "showBranding": true
    },
    "theme": {
        "adminTheme": "light",
        "primaryColor": "#2563eb",
        "accentColor": "#60a5fa"
    }
}
  • favicon — path to primary favicon (SVG or PNG); type attribute is set automatically
  • favicon_png — optional PNG fallback for browsers that don't support SVG favicons; also used as apple-touch-icon
  • branding.logo — path to logo image (empty = text-only)
  • branding.name — site name shown in header and admin
  • theme.adminTheme — "light" or "dark" for admin panel
  • theme.primaryColor / accentColor — used by admin dashboard color picker

Page Template Variables

Templates can set these variables before header.php / footer.php to control page behavior:

Variable Where Purpose
$pageExternalStyles header.php Array of external CSS URLs (e.g. Google Fonts CDN)
$pageExternalScripts footer.php Array of external JS URLs (e.g. map libraries)
$pageStylesheet header.php Path to a page-specific CSS file
$pageClass header.php Extra CSS class on <body>

Site Page Hook

For standard pages (served via includes/page.php + renderAllSections()), create includes/site-page-hook.php to add per-page logic without modifying core files. The hook runs after $slug, $lang, $data, and $basePath are set but before header.php:

<?php
// Example: Load a map library only on specific pages
if (in_array($slug, ['home', 'contact'])) {
    $pageExternalScripts = ['https://cdn.example.com/map.js'];
}

This file survives core updates since it's not part of the Raiju distribution.

Component CSS Classes

Component styles live in css/components.css. This file is Raiju-Core and gets overwritten on upgrade — never add site-specific rules here. Site components belong in css/website.css or, for page-specific styles, css/page-{slug}.css.

Key classes (reserved — site-owners should avoid these names to prevent conflicts):

  • .pricing-grid, .pricing-card, .pricing-card--highlight
  • .faq-accordion, .faq-item, .faq-item__question, .faq-item__answer
  • .testimonials-grid, .testimonial-card
  • .team-grid, .team-card
  • .feature-grid, .feature-card
  • .gallery-grid, .gallery-item
  • .timeline, .timeline-item
  • .stats-grid, .stats-card
  • .comparison-table, .comparison-table-wrap
  • .news-grid, .news-card
  • .breadcrumb

For the full list, run grep -oE "^\.[a-zA-Z][a-zA-Z0-9_-]+" css/components.css | sort -u.

Page Scaffolding Tool

Generate page boilerplate with a single command:

# Standard page (JSON only — front controller serves it)
php cli/make.php --slug=about --lang=en --title="About Us"

# Custom layout page (PHP template + JSON)
php cli/make.php --slug=services --lang=de --type=custom --title="Unsere Dienste"

# Preview without writing files
php cli/make.php --slug=pricing --lang=en --dry-run

# Hide from navigation
php cli/make.php --slug=terms --lang=en --hide-nav

Options: --slug (required), --lang (default: en), --type (standard/custom), --title, --description, --nav=LOCATIONS, --breadcrumb=PATH, --dry-run, --force.

Navigation System

Auto-Discovery

Pages with JSON content files are automatically added to navigation without requiring nav-config.php entries. The system scans content/pages/{lang}_*.json and adds any page not already listed in $NAV_ITEMS.

System partials (home, footer, sidebar, header) are excluded automatically.

Nav Locations ("nav" field)

Control which menus a page appears in via the "nav" array in page JSON. Menu IDs are defined in content/menus.json. Default menus: "header", "footer-pages", "footer-legal".

{ "nav": ["header", "footer-pages"] }
{ "nav": ["header"] }
{ "nav": ["footer-legal"] }
{ "nav": [] }

If "nav" is absent, defaults to ["header"] (auto-discovered in header only).

"nav": [] only hides the page from menus — the URL remains publicly reachable. The front controller serves any existing page slug regardless of nav. If you want the page truly gone, delete its JSON file (or move it out of content/pages/). For migrations where the content was merged into another page, prefer either deleting the JSON and adding a 301 redirect in .htaccess, or returning 410 Gone if there is no replacement (see 410.php).

Menu Registry (content/menus.json)

Defines available menus with multilingual labels and sort order:

{
  "menus": {
    "header": { "label": { "en": "Header", "de": "Kopfzeile" }, "type": "standard", "weight": 0 },
    "footer-pages": { "label": { "en": "Pages", "de": "Seiten" }, "weight": 10 },
    "footer-legal": { "label": { "en": "Info", "de": "Rechtliches" }, "weight": 20 }
  }
}

Footer columns are rendered automatically from the registry (all menus except header). Adding a new footer column = add an entry to menus.json + tag pages with the new menu ID. Column headings come from the label field. The dashboard's Page Settings checkboxes are generated dynamically from the registry.

Nested Menus (Dropdowns)

Group nav items into dropdowns via children in $NAV_ITEMS (nav-config.php):

$NAV_ITEMS = [
    'en' => [
        ['href' => '.', 'label' => 'Home', 'page' => 'home'],
        ['href' => 'docs', 'label' => 'Resources', 'page' => 'docs', 'children' => [
            ['href' => 'docs', 'label' => 'Documentation', 'page' => 'docs'],
            ['href' => 'showcase', 'label' => 'Showcase', 'page' => 'showcase'],
        ]],
        ['href' => 'pricing', 'label' => 'Pricing', 'page' => 'pricing', 'nav' => ['header']],  // header only, not in footer
    ],
];
  • Desktop: hover/focus opens a dropdown below the parent
  • Mobile: children are rendered inline, indented
  • If any child is the active page, the parent also gets the .active class
  • Manual items support an optional 'nav' key (defaults to ['header', 'footer'])

One-Page Menus

Set a menu registry entry to "type": "one-page" when header links point to sections on the homepage:

{ "menus": { "header": { "label": { "en": "Header" }, "type": "one-page", "weight": 0 } } }

Manual $NAV_ITEMS may then use hash-only links such as '#services'. On the homepage Raiju keeps the link as #services; on subpages it normalizes the link to the current language homepage plus hash, e.g. ./#services or de/#services. Hash items do not receive server-side .active. The frontend marks them active only while the target section is visible.

Local Dev Login

Raiju supports a guarded development login for local work:

define('RAIJU_DEV_LOGIN', true);

When enabled, existing admin users can log in with password dev only if both the request host and remote address are loopback (localhost, 127.0.0.1, or ::1). The bypass is ignored on non-local hosts even if the flag is accidentally left enabled. Set RAIJU_DEV_LOGIN to false to disable it locally too. Older local configs without the constant use the same loopback-only behavior.

Breadcrumbs

Optional "breadcrumb" array in page JSON. Each entry has label (required) and href (optional):

{
  "title": "API Reference",
  "breadcrumb": [
    { "label": "Docs", "href": "docs" },
    { "label": "API Reference" }
  ]
}

Standard pages render breadcrumbs automatically above renderAllSections(). Custom layouts call renderBreadcrumb($contentPage, $basePath) manually. If no "breadcrumb" field is present, nothing is rendered.

nav-config.php is still used for: explicit navigation ordering, custom labels, dropdown grouping, the language switcher's $PAGE_MAPPING, and pages that need different slugs per language.

Migration Redirects (content/redirects.json)

For SEO-preserving migrations from another CMS, drop URL → URL mappings into content/redirects.json. The router applies the first matching rule before any other routing — both in production (via route.php after .htaccess rewrites) and in development (via router.php). One file works for both.

{
  "redirects": [
    { "from": "^/wp/about/?$",          "to": "/about",              "status": 301 },
    { "from": "^/wp/page/(.+)$",        "to": "/$1",                 "status": 301 },
    { "from": "^/services/coaching/?$", "to": "/services#coaching",  "status": 301 },
    { "from": "^/temp/(.+)$",           "to": "/hub",                "status": 302 },
    { "from": "^/retired/lifecoach$",                                "status": 410 }
  ]
}
  • from is a PCRE pattern matched against the request path (with leading slash). Backreferences ($1, $2, …) in to are resolved.
  • to is the target URL. Anchors (#section) are preserved as written.
  • status: 301 (default), 302, or 410 (Gone — to is ignored, the branded /410.php is rendered).
  • Rules are evaluated top-to-bottom; the first match wins.
  • Bad regex in a single rule is silently skipped, so a typo doesn't take the whole site down.

A starter file lives at content/redirects.example.json. Copy it to content/redirects.json to activate; the live file is gitignored so site owners can edit it without polluting the repo.

HTML-to-Raiju Converter

Automatically convert a static HTML page into an editable Raiju template + JSON + CSS:

php cli/convert.php my-page.html --slug=about --lang=en --dry-run

The converter parses the HTML, identifies sections, headings, text, images, links, and repeating patterns (cards, testimonials), and generates:

  • A PHP template with editableText(), editableImage(), editableLink(), and editable list calls
  • A JSON content file with all extracted content
  • A CSS file (css/page-{slug}.css) with all styles extracted from <style> blocks, linked local stylesheets, and inline style attributes (converted to classes)
  • External CDN stylesheets (e.g. Google Fonts) are referenced via $pageExternalStyles and auto-loaded by header.php

The generated template sets $pageStylesheet so header.php automatically links the extracted CSS file.

For existing designed sites, the converter is a starting point, not permission to redesign. Preserve the source visual system exactly: keep local CSS/JS/assets and original class names, then use editable helpers in place. If generated CSS or markup differs from the original layout, repair the conversion toward the source page, not toward a new Raiju-styled approximation. Finish with a section-by-section screenshot comparison against the original page.

See cli/README.md for full options (--dry-run, --json-only, --no-css, --force, etc.).

Examples Directory

  • examples/templates/page-simple.php - Standard page using renderAllSections()
  • examples/templates/page-custom.php - Custom layout with editable fields + components
  • examples/templates/page-news.php - News listing page
  • examples/content/all-block-types.json - Every block type in one JSON file
  • examples/content/custom-layout.json - JSON for a custom layout page

Setup & First Run

When converting an HTML page to Raiju or setting up a fresh installation, the setup wizard at /admin/setup.php creates the initial configuration. After setup completes, report the credentials back to the user:

  1. Run the setup wizard (visit /admin/setup.php in the browser or via the dev server)
  2. Choose a username (default: admin)
  3. Generate a strong, unique password using crypto.getRandomValues() or equivalent
  4. Complete the setup form
  5. Always report back the username and generated password so the user can log in:
    Admin login credentials:
    URL:      http://localhost:3000/admin/
    Username: admin
    Password: <the generated password>
    

Never use weak or predictable passwords. The setup wizard enforces: 8+ characters, uppercase, lowercase, digit, and special character.

Site Backups

Raiju has three layers of backup, each addressing a different recovery scenario:

1. Per-page JSON history (automatic, always on)

Every save in the inline editor or admin dashboard writes the previous JSON to backups/{lang}_{slug}_YYYY-MM-DD_HHMMSS.json and keeps the last MAX_BACKUPS (default 30) versions per page. Used to roll back a single page edit without affecting the rest of the site. Visible in the dashboard's per-page history view.

2. Manual full-site backup (on-demand)

Backup tab → "Create backup" downloads a complete site ZIP and also adds it to the server-side pool tagged manual. Manual backups are never auto-evicted by retention or storage limits, so they're the right choice for "snapshot before a risky change."

3. Automated scheduled backups (cron-driven)

The dashboard's "Automated backups" card is the operator surface for cli/backup.php. The CLI runner is meant to live in a system cron job; the dashboard reflects what cron has produced and lets you tune the policy.

Setup:

  1. Configure retention and storage limit in the dashboard (Backup tab → Automated backups).
  2. Toggle "Scheduled backups enabled".
  3. Create a daily scheduled task, usually at 03:00. In Plesk prefer "Run a PHP script": enter the dashboard's script path and set arguments to --action=run. This avoids relying on a php command in Plesk's PATH. In cPanel or shell cron, use php /path/to/site/cli/backup.php --action=run as the job body, replacing php with the absolute PHP CLI binary path if needed.

Default retention (grandfather-father-son): 7 daily, 4 weekly, 12 monthly, 3 yearly — total ~26 long-term snapshots. Each backup is auto-tagged with the highest tier whose bucket (year, month, week, day) is still empty for "now," so a single nightly run fills weekly/monthly/yearly slots automatically when their bucket opens.

Storage budget: The hard storage_limit_mb cap evicts the oldest non-manual backups when total backup size exceeds the limit, even if they're still within their retention window. Manual backups are exempt — the admin asked for them on purpose.

Remote targets: Scheduled backups can upload the finished ZIP to external storage after local creation/pruning. The supported target types are Dropbox, Google Drive, Microsoft OneDrive, SFTP/SCP, S3-compatible object storage, and WebDAV. Target configuration lives under backup.remote_targets in content/settings.json; the dashboard masks saved secrets, and empty secret fields keep their previous value. Remote upload is intentionally a post-create step: local backup creation remains the source of truth, and failed remote uploads are reported without deleting the local ZIP. Dropbox, Google Drive, and Microsoft OneDrive support OAuth PKCE connect flows from the dashboard: enter the provider app/client ID, save/connect, and the callback stores the refresh token for cron uploads. Google/Microsoft client secrets are optional so both public PKCE clients and confidential web apps can be configured. ZIP files use {site-domain}-backup-YYYY-MM-DD_HHMMSS-{tier}.zip, and remote uploads are placed in a per-site subfolder below the configured remote path.

CLI usage (also runnable directly, e.g. for ad-hoc operations):

php cli/backup.php --action=run        # one full backup + prune (the cron-typical path)
php cli/backup.php --action=prune      # apply retention without creating
php cli/backup.php --action=status     # current state
php cli/backup.php --action=list       # all stored backups
php cli/backup.php --action=run --tier=manual  # force a manual backup even when scheduled is off
php cli/backup.php --action=run --skip-remote  # create/prune locally only
php cli/backup.php --action=upload-remote --file=example.com-backup-YYYY-MM-DD_HHMMSS-daily.zip

Lock-file (backups/.backup.lock) prevents concurrent runs. Stale locks (>30 min old) are auto-cleared. Exit codes: 0 success, 1 failure, 2 invalid args, 3 lock contention — useful for cron monitoring scripts.

Storage helper: includes/backup-helper.php is the single source of truth — both the dashboard endpoints in admin/api.php and the CLI runner call into it. ZIP creation, retention math, and tier picking live there only.

Adapting Raiju to an Existing Custom Site

When converting a hand-coded or AI-generated ("vibe-coded") site to use Raiju as its CMS, follow this workflow to avoid layout breakage on future Raiju upgrades:

  1. Move all custom styles to css/website.css. Anything the site owner controls — site-specific layouts, components, brand colors, custom typography — belongs here. Never add custom rules to css/style.css or css/components.css (those are Raiju-Core and get overwritten on update).

  2. Use css/website.css for theme variable overrides. Copy examples/css/website.css.template to css/website.css and uncomment the variables you want to change. The template lists every theming-relevant CSS custom property the Core uses.

  3. In a custom includes/header.php, always call raijuCoreStyles($basePath). This guarantees Core stylesheets stay loaded even after upgrades introduce new ones — site owners never have to re-sync the link list.

    <?php echo raijuCoreStyles($basePath); ?>
    <link rel="stylesheet" href="<?php echo $basePath; ?>css/website.css">
    
  4. Use the JSON auto-write workflow. Page templates with editableText()/editableHtml()/editableImage() calls populate content/pages/{lang}_{slug}.json automatically on the first admin page view — no manual JSON authoring needed for custom layouts.

Upgrading Raiju

For the 1.0.0 API transition, customized footers and storage migration, follow UPGRADING.md in addition to the ownership boundaries below. Publish core releases according to RELEASES.md.

CSS file roles:

File Role Overwritten on upgrade?
css/style.css Raiju-Core: variables, reset, typography, .main-content, .content-inner, .block-* Yes
css/components.css Raiju-Core: reusable block-renderer components Yes
css/inline-editor.css Raiju-Core: editor UI Yes
css/image-manager.css Raiju-Core: image-manager UI Yes
css/revision-client.css Raiju-Core: concurrent-edit conflict dialog Yes
css/raiju-admin-tokens.css Raiju-Core: admin tokens Yes
css/raiju-admin-components.css Raiju-Core: admin components Yes
css/website.css Site-Owner: custom layouts, components, variable overrides No
css/fonts.css Site-Owner: optional, only loaded when present No
css/page-*.css Site-Owner: page-specific (generated by cli/convert.php) No

Whitelist (safely overwritable on upgrade):

  • admin/ except admin/config.php
  • includes/ except includes/header.php, includes/footer.php, includes/nav-config.php, includes/site-page-hook.php
  • Core JS: js/inline-editor.js, js/image-manager.js, js/revision-client.js, js/ai-copilot.js
  • All Raiju-Core CSS files (see table above)
  • api/, cli/, examples/
  • All *.md files except site-owned documentation

Blacklist (NEVER overwrite on upgrade):

  • Everything in content/ (page JSONs, settings, news, events)
  • Everything in assets/ (images, fonts, audio)
  • All language directories (/de/, /en/, etc.)
  • admin/config.php
  • Custom includes/header.php, includes/footer.php, includes/nav-config.php, includes/site-page-hook.php
  • Site frontend JS (e.g. js/main.js, if present)
  • css/website.css, css/fonts.css, css/page-*.css

Common Mistakes to Avoid

editableImage() returns a full <img> tag

Do NOT wrap it in another <img>:

<!-- WRONG — produces <img <img src="..."> -->
<img <?php echo editableImage($_p, 'hero.image', 'photo.jpg', 'Alt text'); ?>>

<!-- CORRECT — editableImage() outputs the complete <img> element -->
<?php echo editableImage($_p, 'hero.image', 'photo.jpg', 'Alt text'); ?>

Same pattern — do NOT wrap it:

<!-- WRONG -->
<a href="#"><?php echo editableLink($_p, 'cta', 'Click', '/page'); ?></a>

<!-- CORRECT -->
<?php echo editableLink($_p, 'cta', 'Click', '/page', 'btn'); ?>

Block type field names must match the renderer

These are the exact field names each block renderer reads. Using different names (e.g. "description" instead of "content", "quote" instead of "text") will silently produce empty output.

Type Required fields
text title, content (HTML), titleTag, style
heading text, level (h1-h6)
quote text, attribution, style
list title, style (bullet/numbered), content (HTML: <ul><li>...</li></ul>)
image src, alt, caption, width
card title, content, image
youtube videoId, title
soundcloud trackId, title
audio src, title
spacer height (sm/md/lg/xl)
divider (none)

Lists use numbered objects, not arrays

// WRONG — JSON array
"items": [{"title": "A"}, {"title": "B"}]

// CORRECT — numbered object keys
"items": {"0": {"title": "A"}, "1": {"title": "B"}}

Dot-notation addressing requires object keys (features.items.0.title). JSON arrays don't support this.

Auto-write only fires for logged-in admins

Visitor traffic never populates the JSON. After creating a new page, an admin must visit it once to trigger auto-write of missing fields.

editableListItems() does NOT auto-write

Unlike editableText()/editableImage()/editableLink(), editableListItems() returns [] for missing keys without creating them in JSON. Editable list structures (with at least one item) must exist in the JSON file.

renderNewsList() signature is ($limit, $lang)

Not ($lang, $basePath). Use renderNewsList(0, $currentLang) for all posts.

Always use $contentPage variable, not hardcoded page names

// WRONG
<?php echo renderAllSections('en_your-page'); ?>

// CORRECT
<?php echo renderAllSections($contentPage); ?>

Rules

  • Know which mode you are in before editing. When adapting a site built with Raiju, avoid modifying Raiju-Core directories such as admin/, includes/, api/, cli/, core css/, and core js/; use content JSON, language/page templates, css/website.css, css/page-*.css, assets, and documented hooks instead so future Raiju updates stay clean. When working on the Raiju core repository itself, changes to core directories are allowed when the user explicitly asks for core behavior, admin UI, API, CLI, backup, routing, or editor changes.
  • GitHub workflow: main is protected. Push feature/test branches and merge into main through a Pull Request; do not expect direct pushes to main to succeed.
  • Content goes in JSON - never hardcode text in templates
  • Use $basePath for all asset and link paths (handles root vs subdirectory serving)
  • BEM class names + CSS custom properties for all styling
  • No inline styles except for PHP-injected dynamic values
  • Page-specific styles belong in css/page-{slug}.css, not in style.css or components.css. Use $pageStylesheet in the template (or let the HTML converter generate it). Selectors like .page-foo .card in the base stylesheets leak page logic into the CMS core.
  • Every <img> needs alt, every interactive element needs :focus-visible
  • Set $contentPage before including header.php so the admin bar works
  • Visits count once per request. includes/header.php calls raijuAnalyticsTrack(); route.php and index.php also schedule raijuAnalyticsScheduleTrack() as a shutdown fallback, so a site with a customised header that lacks the call still counts. Whichever runs first counts; responses with status 300 or above, admins and bots are not visits.

Concurrent editing and modular core

Core request handlers now live in admin/api/; dashboard script fragments live in admin/dashboard/scripts/. Keep their public URLs and action names stable. Read the “Core modules and shared state” section of architecture.md before changing these boundaries, AI request state or the analytics format.

When calling save or save-settings, first load the resource through load or load-settings and include the returned top-level revision in the POST form. Handle HTTP 409 as a concurrent-edit conflict and HTTP 428 as a missing load. Never fetch a newer revision solely to force an old snapshot through. The shared browser revision client already handles normal dashboard and inline-editor forms.