105 KiB
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.phpbuilds 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 compactknowledgeBasewith 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-pagesections[]entries should be traversed into concrete manifest paths such assections.0.contentandsections.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 asfield__hidden, sectionhidden, 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.phpowns 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.jsrenders the chat panel, preview cards, confirmations, image variant selection, alt-text editing, undo, and publish controls. Ifwindow.RJ_ADMIN_API_URLis missing, it must inferadmin/api.phpfrom its own script URL so nested frontend pages still post to the right endpoint.css/ai-copilot.cssstyles the widget independently from public site CSS.admin/lang/*.jsonshould contain user-facing Copilot strings. The widget useswindow.RJ_LANGwith 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 throughincludes/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.jsshould resolve the instruction to the matching manifest subfield:.href/.urlfor target changes,.text/.label/.titlefor 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-visibilityandai-copilot-apply-visibility. Sign the page/path/action/current-state, write only the matchingpath__hiddenflag, 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-imageis called. Do not use nativeprompt()/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 insideraijuAgentValidate(), 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.blockfindings are errors (noready, no present, no apply, no override for anyone);cautionfindings are warnings with asafety_code, shown in the review, andai-agent-applyneedsconfirmRisk=1for them. New text is scanned only where it differs from the stored text. Add words tosafety-patterns.php(folded text: lowercase, no accents), never inline; add a test case totests/agent-safety-smoke.phpfor every new rule. The limits live inRAIJU_AGENT_SAFETY_LIMITSand 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$reportargument counts them, the endpoints answer withcopilot.safety_blockedwhen nothing is left) and addssafetyNoteto odd ones;raijuCopilotNormalizeCreateDraft()refuses harmful drafts and addssafetyNote. Only the text check applies there: every suggestion is one field, previewed and signed. -
Every code in
safety.php($add('mass_removal', …)) needsagent.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_urlreads 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 asvisibility.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 intoassets/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-changesetandai-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 undercontent/, which.htaccessand 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 throughai-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.csssetshtml { font-size: clamp(14px, …, 18px) }, and--rj-d(density, from the viewport or the user's "Dichte" setting viadata-densityon<html>) multiplies spacing and control heights. Do not reintroduce fixed widths for the sidebar, topbar or container. - One base colour.
theme.primaryColoris the base; the stylesheet derives primary, hover, tints, the accent (--rj-accent-base, overridable throughtheme.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 leastl 0.72; nothing stores a separate dark colour any more (darkPrimaryColor/darkAccentColorare accepted but ignored). - Semantic colours.
theme.dangerColor,theme.warningColorandtheme.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:softdefault,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 ofadmin/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,#eaeaeahairlines, 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-primaryis 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::afterwith 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-pillfor the colour,--rj-pop-pill-textfor the text on it, stat tiles cycling base/danger/warning/success/accent throughnth-child); second-level labels as tinted pills; round navigation and swatches. Its preset defaults set the four palette colours like Console does.<html>carriesdata-theme-preset,data-densityanddata-font;admin/dashboard.phpemits--rj-baseand optional sidebar overrides inline,applyTheme()inadmin/dashboard/scripts/settings.phpmirrors that live. - Collapsible sidebar.
<html data-sidebar="collapsed">shrinks the sidebar to its icons (--rj-sidebar-w3.5rem, labels hidden, badges in the corner, tooltips from the label text). The state is a per-browser preference inlocalStorage(raijuSidebarCollapsed), set by a head script before first paint and toggled by#sidebarCollapseBtnat the sidebar's foot; the mobile drawer below 769px ignores it. - Fonts are local.
theme.fontissystem(default, no file),inter,plex(IBM Plex Sans),atkinson(Atkinson Hyperlegible Next),public(Public Sans) orgeist-mono; a storedgeistreads asplex.theme.fontSize(small,default,large,data-font-sizeon<html>) scales the type steps only (--rj-font-scale), never spacing or control heights; faces are declared inadmin/fonts.cssfromadmin/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-actionsright, one.btn-primary); the topbar shows the site name and a breadcrumb viarjSetTopbarTitle(); 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-actionsbar; lists share.page-list-table, hover/focus row actions andrjRenderEmptyRow(); dates go throughrjFormatDate().
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 getid="main-content"automatically; custom templates should add that id or includeincludes/footer.phpso 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
outlineorbox-shadowfocus 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-describedbyplus anaria-livestatus 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:
- 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'; ?>
- Create the matching
content/pages/en_your-page.jsonwith 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:
- Wrap your content in an editable function call instead of hardcoding text
- The function outputs
data-*attributes for admins (e.g.data-page,data-field,data-editable-image) - Content is stored in JSON — the function reads from and writes to
content/pages/{lang}_{slug}.json - 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:
- 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.
- 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.
- 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.
- Keep original public styles site-owned: use
css/website.cssfor shared site CSS andcss/page-{slug}.cssfor page-specific CSS. Raiju core files (css/style.css,css/components.css, core JS) should not absorb customer design rules. - 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.
- 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
- 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".
- 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.
- For standard pages using
sections[], you still create the JSON with the sections array, becauserenderAllSections()iterates over it — there are no PHP fallbacks for section structure. - 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);typeattribute is set automaticallyfavicon_png— optional PNG fallback for browsers that don't support SVG favicons; also used asapple-touch-iconbranding.logo— path to logo image (empty = text-only)branding.name— site name shown in header and admintheme.adminTheme—"light"or"dark"for admin paneltheme.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 ofnav. If you want the page truly gone, delete its JSON file (or move it out ofcontent/pages/). For migrations where the content was merged into another page, prefer either deleting the JSON and adding a 301 redirect in.htaccess, or returning410 Goneif there is no replacement (see410.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
.activeclass - 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 }
]
}
fromis a PCRE pattern matched against the request path (with leading slash). Backreferences ($1,$2, …) intoare resolved.tois the target URL. Anchors (#section) are preserved as written.status:301(default),302, or410(Gone —tois ignored, the branded/410.phpis 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 inlinestyleattributes (converted to classes) - External CDN stylesheets (e.g. Google Fonts) are referenced via
$pageExternalStylesand auto-loaded byheader.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 usingrenderAllSections()examples/templates/page-custom.php- Custom layout with editable fields + componentsexamples/templates/page-news.php- News listing pageexamples/content/all-block-types.json- Every block type in one JSON fileexamples/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:
- Run the setup wizard (visit
/admin/setup.phpin the browser or via the dev server) - Choose a username (default:
admin) - Generate a strong, unique password using
crypto.getRandomValues()or equivalent - Complete the setup form
- 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:
- Configure retention and storage limit in the dashboard (Backup tab → Automated backups).
- Toggle "Scheduled backups enabled".
- 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 aphpcommand in Plesk'sPATH. In cPanel or shell cron, usephp /path/to/site/cli/backup.php --action=runas the job body, replacingphpwith 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:
-
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 tocss/style.cssorcss/components.css(those are Raiju-Core and get overwritten on update). -
Use
css/website.cssfor theme variable overrides. Copyexamples/css/website.css.templatetocss/website.cssand uncomment the variables you want to change. The template lists every theming-relevant CSS custom property the Core uses. -
In a custom
includes/header.php, always callraijuCoreStyles($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"> -
Use the JSON auto-write workflow. Page templates with
editableText()/editableHtml()/editableImage()calls populatecontent/pages/{lang}_{slug}.jsonautomatically 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/exceptadmin/config.phpincludes/exceptincludes/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
*.mdfiles 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'); ?>
editableLink() returns a full <a> tag
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/, corecss/, and corejs/; 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:
mainis protected. Push feature/test branches and merge intomainthrough a Pull Request; do not expect direct pushes tomainto succeed. - Content goes in JSON - never hardcode text in templates
- Use
$basePathfor 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 instyle.cssorcomponents.css. Use$pageStylesheetin the template (or let the HTML converter generate it). Selectors like.page-foo .cardin the base stylesheets leak page logic into the CMS core. - Every
<img>needsalt, every interactive element needs:focus-visible - Set
$contentPagebefore includingheader.phpso the admin bar works - Visits count once per request.
includes/header.phpcallsraijuAnalyticsTrack();route.phpandindex.phpalso scheduleraijuAnalyticsScheduleTrack()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.