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

1961 lines
105 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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
```bash
php -S localhost:3000 router.php
```
For large image uploads raise the CLI limits (the default `upload_max_filesize` is often 2M):
```bash
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](ROUTING.md). 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](ROUTING.md#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
```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
<?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
<?php
echo raijuLazyFormPlaceholder('contact', [
'basePath' => $basePath,
'params' => ['lang' => $currentLang, 'basePath' => $basePath],
]);
?>
```
JSON forms submit to `api/form-submit.php`. The renderer adds the one-time token,
honeypot, `form_id`, loading spans, and `.form-feedback` markup expected by the
shared footer JavaScript. Submissions are stored in `content/mails.json` with
`formId`, `formLabel`, and a structured `fields[]` array; the dashboard inbox can
filter by form.
### Legacy and Custom PHP Forms
The legacy `includes/contact-form.php` and `api/contact.php` continue to work
for older templates. For fully custom PHP forms that do not use JSON rendering,
include `includes/form-protection.php`, call `raijuFormProtectionFields($id)`,
validate with `raijuValidateFormToken()`, and store messages with the same
`formId`/`formLabel` metadata if they should appear clearly in the inbox.
## AI Gateway
Raiju core AI features must use `includes/ai/ai-helper.php` instead of calling
LLM or image-generation vendors directly. The gateway stores provider settings in
`content/ai-settings.json`, supports OpenAI-compatible providers, keeps API keys
server-side in `content/ai-secrets.json` (never in the settings file, which is safe to commit), enforces feature flags, daily/monthly limits, token caps, rough cost
estimates, and writes audit events to `content/ai-audit/*.jsonl`.
Use the existing admin API actions (`ai-chat`, `ai-generate-text`,
`ai-generate-image`, `ai-image-history`, `clear-ai-image-history`,
`load-ai-settings`, `save-ai-settings`, `ai-test`) for dashboard features. API
keys are never exposed by `load-ai-settings`; it only returns `hasApiKey` and
provider metadata. Do not add direct browser-to-provider calls.
Provider credentials are stored per provider, so admins can switch between
OpenAI, OpenRouter, Anthropic (Claude), Ollama, LM Studio, or another
OpenAI-compatible endpoint without replacing keys. The Anthropic provider is
translated to and from the Messages API inside the gateway (including
streaming) so all callers keep one OpenAI-style request/response shape; it
serves chat and text only; images then come from the separate image service. Chat replies can stream over
Server-Sent Events with an automatic fallback to the buffered JSON response.
Local/private URLs (`localhost`, private IP ranges, or self-hosted gateways)
must only be accepted when the explicit local-provider setting is enabled.
Images come from an **image service** that is chosen apart from the chat provider
(`imageService` in `content/ai-settings.json`, section "Images" in the AI settings). Its default
`inherit` uses the chat provider's own image models (not available with Anthropic); the other
choices are OpenAI, OpenRouter, Kie.ai, fal.ai, Comet API and `custom` (any OpenAI-compatible
`/images/generations` endpoint with base URL, model id, size mode `size`|`aspect_ratio`|`none`,
authentication `bearer`|`x-api-key` and optional extra parameters). Resolve it with
`raijuAiImageServiceResolve($settings)`; never read `imageModel` for the effective model, use
`raijuAiEffectiveImageModel()`, and ask `raijuAiImageServiceUsable()` /
`raijuAiImageUnavailableReason()` whether images can be made. Each service has its own key in
`content/ai-secrets.json` (`imageServices.<id>.apiKey`); OpenAI, OpenRouter and Kie.ai fall back to
the chat key of the same provider, only when the chat provider's base URL has the service's host
(`raijuAiImageChatKey()`), so a key never goes to a host it was not made for. Remove one with `ai-clear-secret` (`image:<id>`).
Adapters live in `includes/ai/image-services.php` and share one input (`prompt`, `count`,
`aspectRatio`, `size`, `imageScale`, `outputFormat`, `referencePaths`, ...) that
`raijuAiGenerateImageExecute()` builds; they return the stored image paths. Existing adapters:
`openai-images`, `openrouter-chat`, `kie-task`, `fal-queue` (submit, poll `status_url`, fetch
`response_url`; tasks sit in the request ledger so a stopped job resumes) and `gemini-generate`
(Comet's `generateContent` route). To add a **service**, add an entry to
`includes/ai/image-services.json` (adapter, base URL, `keyFrom`, models) and, for a new protocol,
an adapter function plus its case in `raijuAiImageAdapterRun()`. To add a **model**, add it to the
service's `models` with what it accepts (`aspects`, `aspectParam` or `sizeParam` with `sizePresets`,
`countParam`/`maxCount`, `formatParam`/`formats`, `resolutionParam`/`resolutions`, `references`,
`price`). Set `verified` to the date you checked the parameters against the provider's official
documentation, or `false` when you did not; never guess a parameter or a price. A model that is not
listed uses the `defaults` of its service. Result images are fetched with `raijuNetSafeFetch()`
(`raijuAiImageFetchBinary()`), addresses a provider returns for polling are followed only on the
service's own host, keys never enter audit entries, jobs, history or error messages, and
`raijuAiProviderErrorMessage()` is the one place that reads provider error bodies.
The registry entry of a model may carry its own `adapter` and a `basePath` below the service address (Comet: Nano
Banana over `gemini-generate`, GPT Image 2.5 over `openai-images` below `/v1` with an `openai.params` list of what the
route takes, Seedream over `comet-task`), and for Kie.ai a `kie` block with the task id and the separate image-to-image
id, `resolutionMap` (Kie's Seedream takes `quality` basic/high for 1K/2K), `formatAliases`, `aspectAuto` and
`references.param`. `raijuAiKieTaskInput()` builds a Kie task from it; `raijuAiImageTaskRun()` is the queue runner
shared by fal.ai and Comet's asynchronous route (submit what the ledger does not know, poll, download, clean the ledger).
Reference images: the agent's `generate_image` takes `references` (a `mediaRef` of the run or a
`libraryPath`); `raijuAgentGenerateReferences()` checks them when the tool is called, and
`raijuAiImageServiceSupportsReferences()` says whether the service can use them (OpenAI-style
`/images/edits`, OpenRouter and Kie as before, fal.ai only for models with an `editModel`, Comet by
`inline_data`). Results are labelled `modified`, not `generated`.
Live model lists: `raijuAiImageLiveModels()` (fal.ai `GET /v1/models?category=text-to-image&status=active`,
Comet `GET /api/models`, entries of `model_type` image) feeds the model suggestions through the admin action
`ai-image-models`; add a service with a documented list endpoint to `RAIJU_AI_IMAGE_LIVE_SERVICES` with a
parser. Tests redirect the list hosts with `RAIJU_AGENT_TEST_NET.imageListBases`.
Prices per image: `raijuAiImagePrice()` takes the admin's price for the model
(`pricing.imageModels["<service>|<model>"]`), then the registry's, then the default price per image
(`pricing.imageCentsPerRequest`), otherwise the cost is unknown (reservation 0, `priced:false`). An entry is
`{perImage?, currency, byResolution?: {1K, 2K, 4K}, byQuality?: {low, medium, high}}`; a request is priced by
its resolution step (`raijuAiImageRequestResolution()`), then its quality, then `perImage`.
Generated images are validated and saved under `assets/images/generated/`, are
visible in the Media Library, and are indexed in `content/ai-image-history.json`
with prompt, model, options, response status, and local media paths. OpenAI image
edits use multipart image uploads; OpenRouter-compatible image generation uses
the configured model id and may have provider-specific option support. Never
automatically retry an ambiguous paid POST, even when no response bytes arrived.
Resume known provider task IDs through the request ledger; outcomes without a
task ID need reconciliation before another submission.
Limits are guides, not hard walls (`raijuAiCheckLimits()`): a request that
starts below a limit may finish, and requests of an agent run under way
(`agent_run_*_N` with N > 1, `agent_read_*`) may use up to 10 % more of the daily
request limits and the monthly budget. The first request of a run, standalone
requests and image requests stay strict. `raijuAiUsageSummary()['warnings']`
lists limits at 80 % or reached; the dashboard, the AI settings and the
assistant show them. OpenAI-compatible requests go through
`raijuAiCompatRequest()`, which sends `max_completion_tokens` to api.openai.com
and learns per model (content/ai-model-quirks.json) when a provider rejects
`max_tokens`, `max_completion_tokens` or `temperature`. Agent turns with tools
(`raijuAiChatWithTools()`) use the Responses API (`/responses`, `store: false`,
encrypted reasoning items carried in the assistant message as `reasoningItems`)
for api.openai.com and for models a host refused on chat/completions with
"use /v1/responses"; everything else stays on chat/completions.
Every gateway call serves a purpose (`purpose` option: `chat` for assistant, agent and full
drafts, `text` for field suggestions, rewrites and SEO; `raijuAiGenerateText()` defaults to
`text`). The purpose and the model decide the reasoning effort through the registry
`includes/ai/model-capabilities.json` (`raijuAiReasoningPlan()`); never add a reasoning parameter
for a model that is not listed there, and never send `temperature` to a model that the registry marks
`omitTemperature`. Costs come from `includes/ai/pricing.php`: use `raijuAiCostForUsage()` with the
model that was really used, never a global price; an unknown price is `priced: false`, not 0.
Amounts are cents with four decimals (`raijuAiAddCents()` to sum).
The dashboard assistant (`mode=dashboard` on `ai-copilot-chat`) runs one tool turn with
`open_section`, `show_location` and `change_setting` (`includes/ai/dashboard-actions.php`).
Calls are validated per role and returned as `actions` cards; nothing is changed by the chat.
The card's button calls `window.RaijuDashboardActions.run()` (admin/dashboard/scripts/settings.php),
which fills the existing theme or language form and submits it. To allow another setting,
add it to `RAIJU_DASHBOARD_SETTINGS` and `raijuMapActionEntries()`, validate it in
`raijuDashboardActionFromCall()` (accept the words people and models use, like
`raijuDashboardResolveLanguage()`), add its texts, and handle it in the runner. Whatever the
assistant cannot or may not do must end in a **link card** (`type: link`, a same-site path that
opens in a new tab with `rel="noopener"`), never in "see the settings": `raijuDashboardFinishTurn()`
adds cards found from the person's own words when the model offered none (no tool calling, an
unusable value, an editor asking for an administrator setting). The dashboard system prompt
always carries the map (`raijuDashboardKnowledgePrompt()`), with or without tools.
#### Raiju map (`includes/ai/raiju-map.php`)
The one place that knows where things are in the dashboard: areas (`raijuMapSections()`),
settings tabs (`raijuMapSettingsTabs()`) and the things people look for
(`raijuMapEntries()`: id => area, tab, label key, synonyms, English hint). Names are label
keys of `admin/lang/*.json`, so breadcrumbs ("Settings › Language › Admin Language") exist in all
admin languages; free text is matched against the names in every language plus the synonyms
(`raijuMapLookup()`). Links are built from the script path of the request
(`raijuMapDashboardUrl()`) with the dashboard's own hashes (`#settings/language`). Roles and
switched-off modules are respected: an editor gets the place without a link, a switched-off
module points to Settings › Modules. **When you add a dashboard area, a settings tab or a
setting people ask about, add the entry here**; `tests/raiju-map-smoke.php` compares the map
with `admin/dashboard.php`, requires every name in all nine languages and rejects one synonym on
two entries.
The package preview (`?rj_agent_preview={id}&rj_agent_return={local path}`) opens in the same
tab. Its bar only holds links back to the return path: `#rj-agent-open` and
`#rj-agent-apply={id}`. `js/ai-agent.js` reads that hash once the open package is loaded and
opens the panel or its own apply confirmation. Never add cross-tab messaging or CSRF tokens to
the preview page.
Dashboard AI UI should honor both global module visibility and AI feature flags.
If AI features are disabled, hide AI buttons and tools throughout the admin UI.
If AI is enabled but a capability lacks a model or key, show a clear disabled
state instead of a failing action.
### Frontend AI Assistant
The frontend Copilot is the logged-in editor chat widget loaded by
`includes/footer.php` through `js/ai-copilot.js` and `css/ai-copilot.css`.
It must remain an editor-facing orchestration layer, not a direct AI or file
access layer.
Core files:
- `includes/ai/copilot-context.php` builds the safe page/content manifest,
validates suggested field values, normalizes image paths, creates/publishes
page/news/event drafts, and exposes role-derived Copilot permissions.
It also exposes a compact `knowledgeBase` with curated Raiju guidance from
local product/agent docs. Keep this summary bounded and intentional; do not
stream arbitrary repository files, PHP templates, or raw server documentation
into the frontend Copilot context.
Standard-page `sections[]` entries should be traversed into concrete
manifest paths such as `sections.0.content` and `sections.2.src`; do not
collapse the whole sections array into a single AI target.
Keep structural metadata out of writable Copilot targets: page/lang identity,
timestamps, publication flags, section ids, section types, layout size/level,
and variant/style controls should not be suggested or applied by chat.
Visibility flags such as `field__hidden`, section `hidden`, and status fields
should also stay out of generic field suggestions; hide/show must use the
dedicated signed visibility action instead of an ordinary boolean proposal.
- `admin/api.php` owns all Copilot actions: context, chat, field suggestions,
HTML formatting, visibility previews/applies, confirmed field apply, undo,
content draft/create/publish, and image generation.
- `js/ai-copilot.js` renders the chat panel, preview cards, confirmations,
image variant selection, alt-text editing, undo, and publish controls.
If `window.RJ_ADMIN_API_URL` is missing, it must infer `admin/api.php` from
its own script URL so nested frontend pages still post to the right endpoint.
- `css/ai-copilot.css` styles the widget independently from public site CSS.
- `admin/lang/*.json` should contain user-facing Copilot strings. The widget
uses `window.RJ_LANG` with safe English fallbacks.
Safety rules:
- Never expose provider API keys, raw PHP templates, server files, password
hashes, unrestricted JSON, or local provider internals to the model.
- Browser code must only call `admin/api.php`; provider requests go through
`includes/ai/ai-helper.php`.
- Every write must require an authenticated session, CSRF, a Copilot permission,
and a server-side manifest match. The model output is untrusted.
- Previewed field and image proposals must carry a server-side signature over
page, path, type, current hash, and allowed value hashes. Apply actions must
reject unsigned proposals or values outside the signed preview.
- Field updates are limited to manifest-supported field types. Text, HTML, link,
boolean, known select values such as `seo.robots`, and image replacements have
type-specific validation.
- When a frontend editable link is selected, `js/ai-copilot.js` should resolve
the instruction to the matching manifest subfield: `.href`/`.url` for target
changes, `.text`/`.label`/`.title` for label changes. Do not apply AI output
to the whole link object.
- Simple selected-field HTML formatting requests such as bold, italic, and
underline should use `ai-copilot-format-html` /
`raijuCopilotBuildHtmlFormatProposal()` so they become signed preview
proposals without spending a model call or bypassing confirmation.
- Explicit selected-field hide/show requests should use `ai-copilot-visibility`
and `ai-copilot-apply-visibility`. Sign the page/path/action/current-state,
write only the matching `path__hidden` flag, create a backup, audit the
hidden-state change, and return an Undo token bound to that hidden path.
- HTML suggestions must pass the existing sanitizer. Links must reject unsafe
protocols. Image paths must stay under `/assets/images/` and point to an
existing raster media file (`jpg`, `jpeg`, `png`, `webp`, `gif`). Do not allow
SVG replacement or reference images through the Copilot image workflow.
- Image draft selection must stay inside the Copilot panel: editors choose the
target image field and generate/edit mode before `ai-copilot-generate-image`
is called. Do not use native `prompt()`/`confirm()` dialogs for this image
selection flow.
- Image draft provider options must also stay inside the Copilot panel and be
allowlisted server-side before they reach the provider gateway. Current
Copilot options are size/orientation, variant count, raster output format, and
quality.
- Confirmed page changes must create backups and return a signed, limited undo
token for the same page/path. Do not turn the Copilot into a general backup
browser.
- Copilot proposal audit summaries should store path/type/current hash/value
hash metadata, not raw generated values. Use `raijuCopilotProposalAuditSummary()`
rather than logging proposal payloads directly.
- Content creation must default to safe draft states: pages private/no nav,
news hidden, events hidden. Publishing requires a separate explicit
confirmation and Copilot publish permission.
- Content draft previews must be signed before create actions accept them.
Never accept a page/news/event creation payload that bypasses the preview
signature.
- Image generation is available only when the AI module, AI settings, image
feature flag, image model, and target image field all allow it. Editors choose
whether to use the current field image as an edit reference or generate a new
image from prompt/context only.
- Copilot actions should use `raijuCopilotAssertBurstLimit()` after session
and CSRF checks for session-scoped burst protection. This complements the AI
gateway's daily request, token, and budget limits; it does not replace them.
- Write and image actions should call `raijuAiAudit()` with enough metadata to
debug what happened without logging secrets.
Verification for Copilot changes:
```bash
php -l includes/ai/copilot-context.php && php -l includes/ai/ai-helper.php && php -l admin/api.php && php -l includes/footer.php
node --check js/ai-copilot.js
php tests/copilot-smoke.php
node tests/copilot-api-smoke.js
node tests/copilot-js-smoke.js
php tests/copilot-i18n-smoke.php
find . -maxdepth 3 \( -path './.git' -o -path './relaunch' \) -prune -o -name '*.json' -print | xargs -n 20 php -r 'foreach (array_slice($argv,1) as $f) { json_decode(file_get_contents($f), true); if (json_last_error() !== JSON_ERROR_NONE) { fwrite(STDERR, $f . ": " . json_last_error_msg() . PHP_EOL); exit(1); } }'
git diff --check -- AI-COPILOT-GOAL.md AI-AGENT-GUIDE.md includes/ai/copilot-context.php includes/ai/ai-helper.php admin/api.php includes/footer.php js/ai-copilot.js css/ai-copilot.css tests/copilot-smoke.php tests/copilot-api-smoke.js tests/copilot-js-smoke.js tests/copilot-i18n-smoke.php admin/lang/*.json
```
When a configured local site is available, also run a browser check as a logged-in
editor: confirm that the widget appears only when AI is available, field
suggestions show preview cards before saving, apply creates a backup and undo
works, page/news/event creation starts as draft/private/hidden, publish requires
confirmation, and image generation respects the edit-vs-generate choice.
### AI Agent (Frontend Copilot)
The agent is an optional mode of the assistant chat: on the website (the frontend Copilot) and, since 1.0.0, in the dashboard, where the "Assistant" card of the home tab embeds the chat (`RaijuAiCopilot.embed(host)`; `syncAssistantEmbed()` in `admin/dashboard/scripts/overview-ai.php` moves it between the card and the floating bubble when the tab changes). `js/ai-agent.js` reads `window.RJ_AI_AGENT` (set by `includes/footer.php` and `admin/dashboard.php` under the same conditions) and remembers its mode apart for the two. The editor describes a
goal in the chat; the server works through it with a fixed set of tools, stages
all changes in a **change set** (a package of operations) and presents it. The
editor reviews it (before/after, images, sources, checks), can open a preview
and applies it with one confirmed click. Nothing is written live by the agent.
Classic Copilot flows stay available through the Agent/Classic switch and are
the fallback when the provider cannot call tools.
The agent is off by default. Admins enable it in Dashboard → Settings → AI
Settings → "AI agent" (limits per run, what editors may do, Brave and Pexels
keys, media folder, credit behaviour, "clear page scan cache"). It needs a
provider/model with tool calling (OpenAI-compatible and Anthropic are
supported; Kie.ai is not) and the agent code reads only the `agent`, `research` and `stock`
sections through `raijuAiReadStoredSettings()` (settings file with `content/ai-secrets.json`
merged in; never read the files directly). API keys of these sections are never
returned by `load-ai-settings`; an empty key field on save keeps the saved key.
Files (all under `includes/ai/agent/` unless noted):
| File | Role |
|---|---|
| `agent-config.php`, `settings.php` | Settings, rights (`raijuAgentCan()`), availability. Light enough for every page view. |
| `tool-gateway.php` | `raijuAiChatWithTools()`: tool calling for OpenAI and Anthropic with argument validation, paired history, per-turn request ledger. |
| `net.php` | `raijuNetSafeFetch()`: the only way to fetch external URLs (public addresses only, pinned DNS, manual redirects, size/type limits). |
| `render-scan.php`, `capabilities.php`, `schemas.php` | Render scan of a page template to a capability map (fields, lists, limits). Site overrides in `content/ai-schema/`. |
| `changeset*.php`, `preview.php` | Operations, validation, render check, apply with locks/backups/journal, undo, publish, owner-only preview. |
| `run.php`, `tools.php`, `prompt.php` | The loop: step requests from the browser, tool whitelist, system prompt. |
| `safety.php`, `safety-patterns.php` | The safety check: blast radius of a change set, new text, swapped contact details, first-line screening of requests. Word lists for the nine languages. |
| `../raiju-map.php` | Where things are in the dashboard (see "Raiju map"); used by `find_in_dashboard` and the dashboard assistant. |
| `media.php` | Image ingest (re-encoding, metadata removal), staging, stock photos, credits. |
| `research.php` | Brave search, page extraction, the tool-less reading step, sources. |
| `admin/api/agent.php` | The `ai-agent-*` actions. |
| `js/ai-agent.js` | Cards, timeline, upload, resume; plugs into `js/ai-copilot.js` through `registerAgent()`. |
How a run works: the browser calls `ai-agent-start`, then `ai-agent-step`
repeatedly (each step does one model turn or executes pending tool calls and is
safe against parallel steps). State and transcript live on the server in
`content/ai-agent/runs/`; the browser only sends ids and the user's own words.
Slow tools end the step (web reads); image generation waits in `waiting_jobs`.
Limits per round (turns, tool calls, failures, runtime) and a cost cap per run
apply on top of the gateway's daily and monthly limits.
Tools (`raijuAgentToolDefinitions()`): read tools (`get_site_overview`,
`find_in_dashboard`, `get_page_capabilities`, `read_content`, `list_content`, `list_media`,
`list_run_media`), research (`search_web`, `read_web_result`, `read_url`), images
(`search_stock_images`, `import_stock_image`, `generate_image`), stage tools
(`stage_set_field`, `stage_set_image`, `stage_add_list_item`,
`stage_hide_list_item`, `stage_remove_list_item`, `stage_move_list_item`,
`stage_add_section`, `stage_create_news`, `stage_create_event`,
`stage_create_page`, `stage_revert_op`) and the flow tools (`validate_changeset`,
`ask_user`, `present_changeset`). Every tool has a JSON schema, a right and a
handler; no tool takes a file path, runs code or writes outside the change set.
A new tool needs: a definition with schema and right, a handler, an
`agent.step.<name>` text in all `admin/lang/*.json`, an entry in
`TOOL_STEPS` of `js/ai-agent.js`, and a test.
Security rules for agent work (read before touching `includes/ai/agent/`):
- **Safety check (`safety.php`) judges effects, not words.** `raijuAgentSafetyAssess()` compares the
replayed content with what is stored (before/after per file) and runs inside
`raijuAgentValidate()`, which staging, finalize and apply all call. Never move a check into a
tool handler or the prompt, never trust flags stored in an operation, and never let a new
operation type skip it: the amounts (removed, hidden, cleared parts; lost text; created and touched
targets; menu changes) are summed over the whole change set, so splitting a deletion into
many small operations does not help. `block` findings are errors (no `ready`, no present, no
apply, no override for anyone); `caution` findings are warnings with a `safety_` code, shown in the
review, and `ai-agent-apply` needs `confirmRisk=1` for them. New text is scanned only where it
differs from the stored text. Add words to `safety-patterns.php` (folded text: lowercase, no
accents), never inline; add a test case to `tests/agent-safety-smoke.php` for every new rule. The
limits live in `RAIJU_AGENT_SAFETY_LIMITS` and are lowered, not raised. The patterns are a
heuristic, not a moderation service; the amounts are the part that does not depend on wording.
`raijuAgentSafetyScreenRequest()` refuses obvious requests before the model is called (agent
start and replies, assistant chat); it is a first line, not the enforcement.
- The classic Copilot uses the same word lists through `raijuCopilotSafetyJudge()`
(`raijuAgentSafetyJudgeTexts()`): `raijuCopilotValidateProposals()` drops harmful
suggestions before they are signed (the `$report` argument counts them, the endpoints
answer with `copilot.safety_blocked` when nothing is left) and adds `safetyNote` to odd
ones; `raijuCopilotNormalizeCreateDraft()` refuses harmful drafts and adds `safetyNote`.
Only the text check applies there: every suggestion is one field, previewed and signed.
- Every code in `safety.php` (`$add('mass_removal', …)`) needs `agent.safety.{code}` in all nine
languages; `agent.safety_refuse.{category}` for refusals. Audit entries (`agent-safety`) carry
the stage and codes or category, never text.
- The model output and everything it reads (page content, search results, web
pages, image metadata) is untrusted. Tool results are data, never
instructions; the system prompt says so and the tools enforce it.
- Web pages are never shown to the agent. The server fetches a result with
`raijuNetSafeFetch()` (robots.txt, domain lists, no own host), extracts text
with DOMDocument and gives it to a separate model call **without tools** that
may only answer with a strict JSON schema. URLs, code, instructions and
quotes that are not in the page are dropped; instruction-like pages get a
warning on the source. The agent receives only that JSON, marked untrusted.
- `read_url` reads a page whose address the **user typed** in this run (goal or
replies), matched against the user's own messages; addresses the model found,
built or took from a page are refused. It needs no search key, goes through the
same safe fetch, robots.txt, domain lists, reading step and warnings, and
counts towards the page-read limit.
- External links in written content are allowed only when the user typed the
address or a source of the run was actually fetched. The rule also covers
links inside rich text.
- Targets are keys checked by regex and the capability map (`page:{key}`,
`news:{id}`, `event:{id}`); no paths, no `__proto__`-style keys, no
structural fields such as `visibility.passwordHash`. Private pages enter the
context only when the user names them, and their password hash never does.
- New news, events and pages are created hidden/private; list items and
sections start hidden. Publishing is a separate right and a separate click.
- Images are validated (type, size, pixels), re-encoded without metadata and
staged in `content/ai-agent/staging/`; only images that the applied package
uses move into `assets/images/`, with credit and AI label. Uploaded SVG and GIF
are refused. The AI label is read before re-encoding.
- Every state change is audited (`raijuAgentAudit()`): ids, hashes and counts,
never goal text, page content, fetched text or keys. Search queries are logged
readably because they leave the server.
- All actions are POST with CSRF except the read-only `ai-agent-status`,
`ai-agent-changeset` and `ai-agent-media` (GET); runs, change sets and staged
images belong to their owner (admins see all); rate limits apply per user.
- `content/ai-agent/` must stay non-public (it lives under `content/`, which
`.htaccess` and the dashboard security check cover). Do not log it, do not
link to it, and do not expose staging paths to the browser (the review shows
thumbnails through `ai-agent-media`).
- Test-only network redirection (`RAIJU_AGENT_TEST_NET`, `testTransport`, ...)
works only under the CLI and the built-in server.
Site builders can restrict what the agent may do per page with
`content/ai-schema/` (see SKILLS.md → "ai-schema-override"). Overrides only
restrict: they can lower limits, mark paths read-only, require fields and add
hints; they cannot make something editable.
Verification for agent changes:
```bash
python3 tests/run-smoke.py # includes all agent-*-smoke suites
python3 tests/agent-browser-check.py # Chromium (Playwright): panel, review, apply, undo, settings
node --check js/ai-agent.js
```
The agent suites in `tests/`: `agent-safety` (PHP and HTTP), `raiju-map`, `agent-net`, `agent-scan-mode`, `agent-gateway`,
`agent-capabilities`, `agent-changeset`, `agent-preview`, `agent-tools`,
`agent-loop`, `agent-media-ingest`, `agent-media`, `agent-research-extract`,
`agent-research`, `agent-settings` (PHP and HTTP), `agent-rate-limit`,
`agent-image-label`, `agent-api`, `agent-js` and `agent-security`, plus the
Copilot i18n check for the `agent.*` and `ai.agent_*` texts in all nine
languages.
## Admin UI Conventions
Recent dashboard work tightened several admin surfaces. Preserve these patterns
when adding related UI:
- **Content editor sections**: the left section jump menu is sticky while
scrolling, supports search and type filtering, keeps long labels clamped, and
greys out filtered/non-jumpable items. Keyboard section navigation should keep
the active marker stable after programmatic scrolls.
- **Section editor cards**: type badges should look like metadata, not primary
action buttons. Insert controls between filtered-out sections should not leave
large empty gaps.
- **Messages**: the page is explicitly labelled as contact/form submissions.
Header copy sits with the title, actions align as a toolbar, and the list can
filter by form.
- **Settings forms**: prefer clear native controls (`select`, checkboxes,
buttons) over card-like pseudo-buttons when the interaction is selection.
Compact editor rows should use normal label casing, aligned controls, and no
horizontal overflow.
- **Media and branding previews**: transparent images should be rendered on a
visible checkerboard/transparency surface in dark mode.
- **Dashboard stats**: stat labels align at the top of their cards and numbers
align at the bottom for quick scanning.
- **AI dashboard panel**: hide disabled AI tools. If AI services are disabled or
unconfigured, show only a compact dismissible notice where appropriate.
### Dashboard design system
The dashboard is built from one token set and five shared patterns. Keep to
them when adding admin UI:
- **Tokens live in `css/raiju-admin-tokens.css`**, prefixed `--rj-*`. Spacing
(`--rj-space-1 … 12`, with half steps like `--rj-space-2-5`), a six-step type
scale (`--rj-text-xs … 2xl`), one control height for inputs, selects and
buttons (`--rj-control-h`, `--rj-control-h-sm`), radii and shell geometry
(`--rj-sidebar-w`, `--rj-topbar-h`, `--rj-content-max`). Write new admin CSS
with these tokens, never with pixel values.
- **Everything scales with the root font size.** `admin/style.css` sets
`html { font-size: clamp(14px, …, 18px) }`, and `--rj-d` (density, from the
viewport or the user's "Dichte" setting via `data-density` on `<html>`)
multiplies spacing and control heights. Do not reintroduce fixed widths for
the sidebar, topbar or container.
- **One base colour.** `theme.primaryColor` is the base; the stylesheet derives
primary, hover, tints, the accent (`--rj-accent-base`, overridable through
`theme.accentColor`) and, per preset, the surfaces in OKLCH relative colour
syntax inside `@supports (color: oklch(from red l c h))`. Dark mode lifts the
base to at least `l 0.72`; nothing stores a separate dark colour any more
(`darkPrimaryColor` / `darkAccentColor` are accepted but ignored).
- **Semantic colours.** `theme.dangerColor`, `theme.warningColor` and
`theme.successColor` (empty = auto) reach the stylesheet as
`--rj-danger-base`, `--rj-warning-base`, `--rj-success-base`; the tokens
file derives hover, subtle and text shades from them in OKLCH, light and
dark. Success follows the primary colour unless set. Console's and Pop's preset
defaults set all three along with the base colour.
- **Presets** (`theme.preset`: `soft` default, `classic`, `paper`, `studio`,
`console`, `pop`) set surfaces, borders, radii, control heights and the sidebar/topbar tokens
and bring a font (Soft Inter, Classic Public Sans, Paper Atkinson Hyperlegible, Studio IBM
Plex Sans, Console Geist Mono, Pop Atkinson Hyperlegible)
(`--rj-sidebar-*`, `--rj-topbar-*`). Admin CSS reads those tokens and never
hard-codes chrome colours; the few per-preset component rules (table heads,
button surfaces, sidebar item geometry) live in the "PRESET REFINEMENTS"
block at the end of `admin/style.css`. Two presets follow published
references: **Soft** uses the shadcn/ui measures (zinc greys, 36 px / 32 px
controls, 8 px cards / 6 px controls, 40 px table heads in muted text) on a
faintly tinted shell; **Studio** follows the Vercel dashboard / Geist (pure
neutral greys, `#eaeaea` hairlines, black buttons, white on black in dark
mode, 40 px / 32 px controls, 6 px radius). In Studio the base colour is an
accent only (focus ring, success, badges), so `--rj-primary` is near-black
there and must not be assumed to carry the brand hue. **Console** is the
ops-console look: Geist Mono as the UI face (`theme.font: geist-mono`), a
smaller type scale, caps with tracking on labels, heads, navigation and
buttons, square corners, dashed group dividers, and a crosshair on selected
elements (four corner brackets drawn by one `::after` with eight gradient
strokes; see `--rj-xh-*` in the Console block). The base colour keeps its
full chroma there. **Pop** is the playful one: sidebar and topbar in a deep
slate derived from the base hue (`--rj-sidebar-bg`), the sidebar running the
gradient token `--rj-sidebar-gradient` (built from `--rj-sidebar-bg`, so a
custom sidebar colour keeps the gradient); a light stage with white cards
and soft shadows; legends, panel titles, stat and status labels as pills
(`--rj-pop-pill` for the colour, `--rj-pop-pill-text` for the text on it,
stat tiles cycling base/danger/warning/success/accent through `nth-child`);
second-level labels as tinted pills; round navigation and swatches. Its
preset defaults set the four palette colours like Console does.
`<html>` carries `data-theme-preset`,
`data-density` and `data-font`; `admin/dashboard.php` emits `--rj-base` and
optional sidebar overrides inline, `applyTheme()` in
`admin/dashboard/scripts/settings.php` mirrors that live.
- **Collapsible sidebar.** `<html data-sidebar="collapsed">` shrinks the
sidebar to its icons (`--rj-sidebar-w` 3.5rem, labels hidden, badges in
the corner, tooltips from the label text). The state is a per-browser
preference in `localStorage` (`raijuSidebarCollapsed`), set by a head
script before first paint and toggled by `#sidebarCollapseBtn` at the
sidebar's foot; the mobile drawer below 769px ignores it.
- **Fonts are local.** `theme.font` is `system` (default, no file), `inter`, `plex`
(IBM Plex Sans), `atkinson` (Atkinson Hyperlegible Next), `public` (Public Sans) or
`geist-mono`; a stored `geist` reads as `plex`. `theme.fontSize` (`small`, `default`,
`large`, `data-font-size` on `<html>`) scales the type steps only (`--rj-font-scale`),
never spacing or control heights; faces are declared in `admin/fonts.css` from `admin/fonts/`. Never
link an external font service in the admin.
- **Feature stylesheets** (`css/ai-copilot.css`, `css/image-manager.css`) are
loaded on the public site too, so they read tokens with fallbacks:
`var(--rj-space-3, 12px)`, `var(--rj-text, #172033)`. Keep that form there.
In dark mode the primary colour is light, so anything on it uses
`--rj-text-on-primary` (dark in dark mode), never a literal white.
- **Patterns**: every tab starts with `.page-list-header` (title + description
left, `.page-list-header-actions` right, one `.btn-primary`); the topbar shows
the site name and a breadcrumb via `rjSetTopbarTitle()`; cards have at most
two levels (fieldsets inside a settings card are groups with a divider, not a
second box); settings forms use label-left rows from 1100px and end in a
sticky `.settings-actions` bar; lists share `.page-list-table`, hover/focus
row actions and `rjRenderEmptyRow()`; dates go through `rjFormatDate()`.
### Select carets
Load `css/rj-select.css` for admin/editor controls. Enhanced dropdowns and
`select[data-native-select]` share a 14px caret inset through
`--rj-select-caret-inset`. The native attribute retains the browser picker and
keyboard behavior, while CSS draws the closed-state caret with reserved text
space. Do not restore `appearance: auto` or replace its background shorthand in
component styles. Multiple/size listboxes are excluded; forced-colors mode
restores the system arrow. RTL fields move the caret to the left automatically.
Use `--rj-select-caret-image` only when a theme needs a different caret color.
Admin CSS is cache-busted from `admin/dashboard.php` with `filemtime()` so
layout changes in `admin/style.css` are visible after reload.
## Multilingual Admin Editor Fields
Admin/editor dialogs can group language variants of the same field into tabs.
This is a Core UI feature for editor modals and structured admin editors, not
for public visitor forms.
Use either a language-object field with `localized: true`:
```php
[
'label' => 'Event',
'fields' => [
['key' => 'title', 'type' => 'input', 'label' => 'Title', 'localized' => true],
['key' => 'description', 'type' => 'textarea', 'label' => 'Description', 'localized' => true],
['key' => 'url', 'type' => 'url', 'label' => 'URL'],
],
]
```
or explicit language-suffixed paths such as `title.de`, `title.en`,
`description.de`, and `description.en`. Raiju reads language codes from
`$SITE_LANGUAGES`; one-language sites render the fields normally without tabs.
Fields that are not language-specific remain outside the tabs. The tab UI is a
rendering layer only, so saving keeps the same JSON paths and data shape.
When the AI module is enabled, multilingual editor tabs show a translation
button for the active source language. The generated translations are written
only into the still-open dialog fields and are persisted only if the editor uses
the normal Save action.
## Accessibility Best Practices
Raiju Core provides the baseline accessibility plumbing for public pages and
admin UI: a skip link, `main-content` target handling, landmark-friendly header
and footer navigation, visible focus states, reduced-motion CSS, labelled
toolbar controls, modal dialog semantics, live regions for toast/status
messages, and SEO health checks for missing image alt text.
When creating or adapting site templates, preserve that foundation:
- Use one primary `<main>` element per page. Standard JSON pages get
`id="main-content"` automatically; custom templates should add that id or
include `includes/footer.php` so the fallback can attach it to the first
`<main>`.
- Keep heading hierarchy semantic. Use one visible H1 for the page topic, then
H2/H3 sections in order. Do not choose heading levels only for visual size.
- Provide meaningful alt text for informative images. Use an empty alt
(`alt=""`) only for decorative images. The page SEO health indicator reports
missing image alt text.
- Use native elements: `<button>` for actions, `<a href>` for navigation, and
`<label for>` for form controls. Avoid click handlers on plain `<div>` or
`<span>` elements.
- Keep focus visible. Do not remove `outline` or `box-shadow` focus states
unless a replacement with comparable contrast is added.
- Respect `prefers-reduced-motion`. Page-specific animation CSS should include a
reduced-motion path or rely on the Core global reduction rules.
- Connect form errors and hints to their fields with nearby text and, when
custom JavaScript is involved, `aria-describedby` plus an `aria-live` status
area.
- Test new public templates with keyboard-only navigation before launch: Tab,
Shift+Tab, Enter/Space, Escape for overlays, and visible focus order.
## Frontend Access Controls
Raiju's access controls are implemented centrally in `includes/access-guard.php`.
They are loaded by `includes/header.php`, so custom templates must set
`$contentPage` before including the header if page-level privacy should apply.
Standard JSON pages already do this through `includes/page.php`.
### Maintenance Mode
Editors configure maintenance mode in **Dashboard -> Settings -> Access**. The
settings are stored under `access.maintenance` in `content/settings.json`:
```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:
```json
{
"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:
```bash
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:
```bash
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:
```json
{
"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.
```json
{
"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):
```bash
php cli/make.php --slug=your-page --lang=en --type=custom --title="My Page"
```
This creates both `en/your-page.php` and `content/pages/en_your-page.json` with hero + content boilerplate. Edit the PHP template to customize the layout.
To create manually instead:
1. Create `{lang}/your-page.php`:
```php
<?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'; ?>
```
2. Create the matching `content/pages/en_your-page.json` with the same dot-notation keys.
## Block Types
These types are available in `sections[]` arrays for standard pages:
| Type | Category | Key Fields |
|---|---|---|
| `text` | content | `title`, `content` (HTML), `titleTag`, `style` |
| `heading` | content | `text`, `level` (h1-h6), `subtitle` |
| `quote` | content | `text`, `attribution`, `style` (default/large) |
| `list` | content | `title`, `style` (bullet/numbered), `content` (HTML) |
| `image` | media | `src`, `alt`, `caption`, `width` (full/medium/small) |
| `card` | cards | `title`, `content`, `image` |
| `youtube` | media | `title`, `videoId` |
| `soundcloud` | media | `title`, `trackId` |
| `audio` | media | `title`, `src` |
| `divider` | layout | *(no fields)* |
| `spacer` | layout | `height` (sm/md/lg/xl) |
## How Inline Editing Works
The frontend admin bar lists persisted backups of the current content page when
available, in both browse and edit mode. The dropdown uses local timestamps;
Apply opens a confirmation and warns about unsaved page/shared edits. Restoring
checks the loaded page revision, backs up the saved state first, reloads the
server-rendered page, and resumes the previous edit mode. Page backups contain
content and image references; they do not recover deleted media files. News-post
pages do not show this page-only control. Keep this separate from session Undo.
While logged in and browsing with editing disabled, two clicks on non-interactive
page areas open a one-time reminder with an **Activate visual editor** button.
Links, forms, controls, open dialogs and active edit mode are excluded. Dismissing
the reminder suppresses it until the next page load; it never saves content.
**This is what Raiju does.** You take any existing HTML page — hand-built, designed in Figma, or vibe-coded with AI — and make it editable by swapping hardcoded content for Raiju's PHP helper functions. Each function injects `data-*` attributes that the inline editor JavaScript discovers. Logged-in admins click to edit text, swap images, and change links directly on the page. Content gets stored in JSON files. Visitors see clean HTML with zero editor markup. No database, no migration, no complex setup — just a PHP include and a few function calls turn a static page into a CMS.
**Existing-site rule: preserve, do not rebuild.** When converting an existing
site, treat the source HTML/CSS/JS/assets as the visual source of truth. Copy
local stylesheets, scripts, media, fonts, class names, layout wrappers,
animation hooks, and responsive rules before adding Raiju fields. Do not
recreate the design with new tokens, new grids, new cards, simplified markup,
or approximate CSS. Raiju conversion is a surgical content edit: keep the
rendered page visually identical, then make its text, images, links, and
repeaters editable.
The pattern is always the same:
1. **Wrap your content** in an editable function call instead of hardcoding text
2. **The function outputs `data-*` attributes** for admins (e.g. `data-page`, `data-field`, `data-editable-image`)
3. **Content is stored in JSON** — the function reads from and writes to `content/pages/{lang}_{slug}.json`
4. **Visitors see plain HTML** — no attributes, no editor UI
For a pre-existing designed page, these steps happen inside the original
structure. Preserve the same element hierarchy wherever possible. If an
editable helper introduces a wrapper that affects CSS selectors or spacing, add
the smallest site-owned compatibility rule in `css/website.css` or
`css/page-{slug}.css`; do not rewrite the component.
### Visual-Preserving Migration Checklist
Use this checklist before and after converting any existing site:
1. Start the original page locally and capture reference screenshots for the
hero, every section, footer, key hover/open states, scroll-pinned or animated
areas, and mobile breakpoints.
2. Copy original local CSS, JS, assets, fonts, favicon files, and media paths
into the Raiju project. Keep source class names and data attributes that
scripts rely on.
3. Convert by replacing hardcoded text, images, links, and repeatable data with
editable helpers. Do not change layout wrappers, grid structure, animation
containers, or CSS selectors unless the helper API makes it unavoidable.
4. Keep original public styles site-owned: use `css/website.css` for shared
site CSS and `css/page-{slug}.css` for page-specific CSS. Raiju core files
(`css/style.css`, `css/components.css`, core JS) should not absorb customer
design rules.
5. Run the converted page locally and capture the same screenshots. Compare
section by section against the reference. Check computed styles for fragile
details such as header transparency, backdrop filters, font weights,
gradients, border radii, image crop/object-position, padding, card sizes,
z-index, and scroll animation backgrounds.
6. Treat visual drift as a bug. Only accept a design difference when the user
explicitly requested a redesign or approved the tradeoff.
Anti-patterns for existing-site migrations:
- Rebuilding the stylesheet from memory or screenshots instead of copying it.
- Replacing original page CSS with Raiju theme variables unless it is a
deliberate, reviewed refactor.
- Changing cards, wrappers, or layout grids to "fit" an editable abstraction.
- Simplifying scroll/animation sections, canvas/WebGL scenes, or hover states.
- Guessing font weights, spacing, gradients, image crops, or breakpoint rules.
### Example: Before and After
**Static HTML (not editable):**
```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">
```
**Raiju-editable version:**
```php
<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):**
```html
<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):**
```html
<h1>Welcome to My Site</h1>
<p>We build great things.</p>
<a href="/contact" class="btn">Contact Us</a>
<img src="assets/images/hero.jpg" alt="Hero">
```
### Data Attributes Reference
| Attribute | On | Purpose |
|---|---|---|
| `class="editable-field"` | text/html | JS discovers text fields via this class |
| `class="editable-field-html"` | html | Distinguishes rich-text from plain-text fields |
| `data-page` | all | Which JSON file to read/write (e.g. `en_home`) |
| `data-field` | all | Dot-notation key in JSON (e.g. `hero.title`) |
| `data-editable-link` | links | JS discovers editable links via this attribute |
| `data-editable-image` | images | JS discovers editable images via this attribute |
| `data-editable-group` | component wrappers | Opens one modal for several custom-layout fields |
| `data-group` | grouped component wrappers | Dot path used as the base for grouped fields |
| `data-group-schema` | grouped component wrappers | JSON schema describing the grouped editor fields |
| `data-editable-list` | list containers | Marks a repeating-items container |
| `data-list-key` | list containers/items | Dot path to items object |
| `data-list-index` | list items | Numeric index (0, 1, 2...) |
| `data-hidden` | any | Hides element from visitors when `"true"` |
| `data-allow-field-hide` | text/link fields | Opts a specific field into field-level hide controls |
## JSON Is the Source of Truth
The JSON file (`content/pages/{lang}_{slug}.json`) is the single source of truth for all editable content. Both the Visual Editor (frontend) and the Content Editor (backend dashboard) read from it.
### Auto-Write: How Missing Fields Get Populated
When an admin browses a page, every `editableText()`, `editableHtml()`, `editableImage()`, and `editableLink()` call checks whether its key exists in the JSON file. If the key is missing, Raiju **automatically writes the fallback value to the JSON file**. This means:
- An agent (or human) can focus on the PHP template — just use `editableText($_p, 'hero.title', 'Welcome to My Site')` with a good fallback value.
- On first admin page view, any missing keys are written to the JSON using those fallback values.
- The Content Editor will then show all fields, because the JSON is now populated.
- A toast notification tells the admin how many fields were auto-generated (e.g. "Auto-wrote 4 missing fields to en_about.json").
**This is the intended workflow.** Auto-write exists so you don't have to maintain content in two places.
### What This Means for Agents
1. **Always provide meaningful fallback values** — they become the initial content in the JSON. Write fallbacks as if they were the real content, not just "Default Title".
2. **You don't need to pre-populate the JSON file** for custom layout pages. The PHP template + fallbacks are enough. The JSON will be populated on first admin render.
3. **For standard pages using `sections[]`**, you still create the JSON with the sections array, because `renderAllSections()` iterates over it — there are no PHP fallbacks for section structure.
4. **For editable lists**, the list structure must exist in JSON (since `editableListItems()` returns `[]` for missing keys and does not auto-write). Individual item fields within an existing list do auto-write.
### Example: Custom Layout Page
The agent writes the PHP template with good fallbacks:
```php
<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):
```json
{
"page": "en_about",
"lang": "en",
"title": "About Us",
"description": "Learn more about us."
}
```
After the first admin page view, the JSON is automatically populated:
```json
{
"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
```php
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.
```php
<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
<?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.):
```php
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:**
```php
<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:
```php
<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:**
```php
<div class="about-text">
<?php echo editableTextList($_p, 'about.paragraphs'); ?>
</div>
```
```json
{
"about": {
"paragraphs": {
"0": { "content": "First paragraph text..." },
"1": { "content": "Second paragraph with <strong>formatting</strong>..." }
}
}
}
```
### Render Components
```php
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
```php
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`:
```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`)
```json
{
"favicon": "/assets/images/favicon.svg",
"favicon_png": "/assets/images/favicon.png",
"branding": {
"logo": "",
"name": "My Site",
"showBranding": true
},
"theme": {
"adminTheme": "light",
"primaryColor": "#2563eb",
"accentColor": "#60a5fa"
}
}
```
- `favicon` — path to primary favicon (SVG or PNG); `type` attribute is set automatically
- `favicon_png` — optional PNG fallback for browsers that don't support SVG favicons; also used as `apple-touch-icon`
- `branding.logo` — path to logo image (empty = text-only)
- `branding.name` — site name shown in header and admin
- `theme.adminTheme` — `"light"` or `"dark"` for admin panel
- `theme.primaryColor` / `accentColor` — used by admin dashboard color picker
## Page Template Variables
Templates can set these variables before `header.php` / `footer.php` to control page behavior:
| Variable | Where | Purpose |
|---|---|---|
| `$pageExternalStyles` | header.php | Array of external CSS URLs (e.g. Google Fonts CDN) |
| `$pageExternalScripts` | footer.php | Array of external JS URLs (e.g. map libraries) |
| `$pageStylesheet` | header.php | Path to a page-specific CSS file |
| `$pageClass` | header.php | Extra CSS class on `<body>` |
## Site Page Hook
For standard pages (served via `includes/page.php` + `renderAllSections()`), create `includes/site-page-hook.php` to add per-page logic without modifying core files. The hook runs after `$slug`, `$lang`, `$data`, and `$basePath` are set but before `header.php`:
```php
<?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:
```bash
# 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"`.
```json
{ "nav": ["header", "footer-pages"] }
{ "nav": ["header"] }
{ "nav": ["footer-legal"] }
{ "nav": [] }
```
If `"nav"` is absent, defaults to `["header"]` (auto-discovered in header only).
> **`"nav": []` only hides the page from menus — the URL remains publicly reachable.**
> The front controller serves any existing page slug regardless of `nav`. If you want the page truly gone, delete its JSON file (or move it out of `content/pages/`). For migrations where the content was merged into another page, prefer either deleting the JSON and adding a 301 redirect in `.htaccess`, or returning `410 Gone` if there is no replacement (see `410.php`).
### Menu Registry (`content/menus.json`)
Defines available menus with multilingual labels and sort order:
```json
{
"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`):
```php
$NAV_ITEMS = [
'en' => [
['href' => '.', 'label' => 'Home', 'page' => 'home'],
['href' => 'docs', 'label' => 'Resources', 'page' => 'docs', 'children' => [
['href' => 'docs', 'label' => 'Documentation', 'page' => 'docs'],
['href' => 'showcase', 'label' => 'Showcase', 'page' => 'showcase'],
]],
['href' => 'pricing', 'label' => 'Pricing', 'page' => 'pricing', 'nav' => ['header']], // header only, not in footer
],
];
```
- Desktop: hover/focus opens a dropdown below the parent
- Mobile: children are rendered inline, indented
- If any child is the active page, the parent also gets the `.active` class
- Manual items support an optional `'nav'` key (defaults to `['header', 'footer']`)
### One-Page Menus
Set a menu registry entry to `"type": "one-page"` when header links point to
sections on the homepage:
```json
{ "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:
```php
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):
```json
{
"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.
```json
{
"redirects": [
{ "from": "^/wp/about/?$", "to": "/about", "status": 301 },
{ "from": "^/wp/page/(.+)$", "to": "/$1", "status": 301 },
{ "from": "^/services/coaching/?$", "to": "/services#coaching", "status": 301 },
{ "from": "^/temp/(.+)$", "to": "/hub", "status": 302 },
{ "from": "^/retired/lifecoach$", "status": 410 }
]
}
```
- `from` is a PCRE pattern matched against the request path (with leading slash). Backreferences (`$1`, `$2`, …) in `to` are resolved.
- `to` is the target URL. Anchors (`#section`) are preserved as written.
- `status`: `301` (default), `302`, or `410` (Gone — `to` is ignored, the branded `/410.php` is rendered).
- Rules are evaluated top-to-bottom; the first match wins.
- Bad regex in a single rule is silently skipped, so a typo doesn't take the whole site down.
A starter file lives at `content/redirects.example.json`. Copy it to `content/redirects.json` to activate; the live file is gitignored so site owners can edit it without polluting the repo.
## HTML-to-Raiju Converter
Automatically convert a static HTML page into an editable Raiju template + JSON + CSS:
```bash
php cli/convert.php my-page.html --slug=about --lang=en --dry-run
```
The converter parses the HTML, identifies sections, headings, text, images, links, and repeating patterns (cards, testimonials), and generates:
- A PHP template with `editableText()`, `editableImage()`, `editableLink()`, and editable list calls
- A JSON content file with all extracted content
- A CSS file (`css/page-{slug}.css`) with all styles extracted from `<style>` blocks, linked local stylesheets, and inline `style` attributes (converted to classes)
- External CDN stylesheets (e.g. Google Fonts) are referenced via `$pageExternalStyles` and auto-loaded by `header.php`
The generated template sets `$pageStylesheet` so `header.php` automatically links the extracted CSS file.
For existing designed sites, the converter is a starting point, not permission
to redesign. Preserve the source visual system exactly: keep local CSS/JS/assets
and original class names, then use editable helpers in place. If generated CSS
or markup differs from the original layout, repair the conversion toward the
source page, not toward a new Raiju-styled approximation. Finish with a
section-by-section screenshot comparison against the original page.
See `cli/README.md` for full options (`--dry-run`, `--json-only`, `--no-css`, `--force`, etc.).
## Examples Directory
- `examples/templates/page-simple.php` - Standard page using `renderAllSections()`
- `examples/templates/page-custom.php` - Custom layout with editable fields + components
- `examples/templates/page-news.php` - News listing page
- `examples/content/all-block-types.json` - Every block type in one JSON file
- `examples/content/custom-layout.json` - JSON for a custom layout page
## Setup & First Run
When converting an HTML page to Raiju or setting up a fresh installation, the setup wizard at `/admin/setup.php` creates the initial configuration. **After setup completes, report the credentials back to the user:**
1. Run the setup wizard (visit `/admin/setup.php` in the browser or via the dev server)
2. Choose a username (default: `admin`)
3. Generate a strong, unique password using `crypto.getRandomValues()` or equivalent
4. Complete the setup form
5. **Always report back** the username and generated password so the user can log in:
```
Admin login credentials:
URL: http://localhost:3000/admin/
Username: admin
Password: <the generated password>
```
Never use weak or predictable passwords. The setup wizard enforces: 8+ characters, uppercase, lowercase, digit, and special character.
## Site Backups
Raiju has three layers of backup, each addressing a different recovery scenario:
### 1. Per-page JSON history (automatic, always on)
Every save in the inline editor or admin dashboard writes the previous JSON to `backups/{lang}_{slug}_YYYY-MM-DD_HHMMSS.json` and keeps the last `MAX_BACKUPS` (default 30) versions per page. Used to roll back a single page edit without affecting the rest of the site. Visible in the dashboard's per-page history view.
### 2. Manual full-site backup (on-demand)
Backup tab → "Create backup" downloads a complete site ZIP and also adds it to the server-side pool tagged `manual`. Manual backups are never auto-evicted by retention or storage limits, so they're the right choice for "snapshot before a risky change."
### 3. Automated scheduled backups (cron-driven)
The dashboard's "Automated backups" card is the operator surface for `cli/backup.php`. The CLI runner is meant to live in a system cron job; the dashboard reflects what cron has produced and lets you tune the policy.
**Setup:**
1. Configure retention and storage limit in the dashboard (Backup tab → Automated backups).
2. Toggle "Scheduled backups enabled".
3. Create a daily scheduled task, usually at 03:00. In Plesk prefer "Run a PHP script": enter the dashboard's script path and set arguments to `--action=run`. This avoids relying on a `php` command in Plesk's `PATH`. In cPanel or shell cron, use `php /path/to/site/cli/backup.php --action=run` as the job body, replacing `php` with the absolute PHP CLI binary path if needed.
**Default retention** (grandfather-father-son): 7 daily, 4 weekly, 12 monthly, 3 yearly — total ~26 long-term snapshots. Each backup is auto-tagged with the highest tier whose bucket (year, month, week, day) is still empty for "now," so a single nightly run fills weekly/monthly/yearly slots automatically when their bucket opens.
**Storage budget:** The hard `storage_limit_mb` cap evicts the oldest non-manual backups when total backup size exceeds the limit, even if they're still within their retention window. Manual backups are exempt — the admin asked for them on purpose.
**Remote targets:** Scheduled backups can upload the finished ZIP to external storage after local creation/pruning. The supported target types are Dropbox, Google Drive, Microsoft OneDrive, SFTP/SCP, S3-compatible object storage, and WebDAV. Target configuration lives under `backup.remote_targets` in `content/settings.json`; the dashboard masks saved secrets, and empty secret fields keep their previous value. Remote upload is intentionally a post-create step: local backup creation remains the source of truth, and failed remote uploads are reported without deleting the local ZIP. Dropbox, Google Drive, and Microsoft OneDrive support OAuth PKCE connect flows from the dashboard: enter the provider app/client ID, save/connect, and the callback stores the refresh token for cron uploads. Google/Microsoft client secrets are optional so both public PKCE clients and confidential web apps can be configured. ZIP files use `{site-domain}-backup-YYYY-MM-DD_HHMMSS-{tier}.zip`, and remote uploads are placed in a per-site subfolder below the configured remote path.
**CLI usage** (also runnable directly, e.g. for ad-hoc operations):
```bash
php cli/backup.php --action=run # one full backup + prune (the cron-typical path)
php cli/backup.php --action=prune # apply retention without creating
php cli/backup.php --action=status # current state
php cli/backup.php --action=list # all stored backups
php cli/backup.php --action=run --tier=manual # force a manual backup even when scheduled is off
php cli/backup.php --action=run --skip-remote # create/prune locally only
php cli/backup.php --action=upload-remote --file=example.com-backup-YYYY-MM-DD_HHMMSS-daily.zip
```
Lock-file (`backups/.backup.lock`) prevents concurrent runs. Stale locks (>30 min old) are auto-cleared. Exit codes: `0` success, `1` failure, `2` invalid args, `3` lock contention — useful for cron monitoring scripts.
**Storage helper:** `includes/backup-helper.php` is the single source of truth — both the dashboard endpoints in `admin/api.php` and the CLI runner call into it. ZIP creation, retention math, and tier picking live there only.
## Adapting Raiju to an Existing Custom Site
When converting a hand-coded or AI-generated ("vibe-coded") site to use Raiju as its CMS, follow this workflow to avoid layout breakage on future Raiju upgrades:
1. **Move all custom styles to `css/website.css`.** Anything the site owner controls — site-specific layouts, components, brand colors, custom typography — belongs here. Never add custom rules to `css/style.css` or `css/components.css` (those are Raiju-Core and get overwritten on update).
2. **Use `css/website.css` for theme variable overrides.** Copy `examples/css/website.css.template` to `css/website.css` and uncomment the variables you want to change. The template lists every theming-relevant CSS custom property the Core uses.
3. **In a custom `includes/header.php`, always call `raijuCoreStyles($basePath)`.** This guarantees Core stylesheets stay loaded even after upgrades introduce new ones — site owners never have to re-sync the link list.
```php
<?php echo raijuCoreStyles($basePath); ?>
<link rel="stylesheet" href="<?php echo $basePath; ?>css/website.css">
```
4. **Use the JSON auto-write workflow.** Page templates with `editableText()`/`editableHtml()`/`editableImage()` calls populate `content/pages/{lang}_{slug}.json` automatically on the first admin page view — no manual JSON authoring needed for custom layouts.
## Upgrading Raiju
For the 1.0.0 API transition, customized footers and storage migration, follow
[UPGRADING.md](UPGRADING.md) in addition to the ownership boundaries below.
Publish core releases according to [RELEASES.md](RELEASES.md).
CSS file roles:
| File | Role | Overwritten on upgrade? |
|---|---|---|
| `css/style.css` | Raiju-Core: variables, reset, typography, `.main-content`, `.content-inner`, `.block-*` | **Yes** |
| `css/components.css` | Raiju-Core: reusable block-renderer components | **Yes** |
| `css/inline-editor.css` | Raiju-Core: editor UI | **Yes** |
| `css/image-manager.css` | Raiju-Core: image-manager UI | **Yes** |
| `css/revision-client.css` | Raiju-Core: concurrent-edit conflict dialog | **Yes** |
| `css/raiju-admin-tokens.css` | Raiju-Core: admin tokens | **Yes** |
| `css/raiju-admin-components.css` | Raiju-Core: admin components | **Yes** |
| `css/website.css` | Site-Owner: custom layouts, components, variable overrides | **No** |
| `css/fonts.css` | Site-Owner: optional, only loaded when present | **No** |
| `css/page-*.css` | Site-Owner: page-specific (generated by `cli/convert.php`) | **No** |
**Whitelist** (safely overwritable on upgrade):
- `admin/` except `admin/config.php`
- `includes/` except `includes/header.php`, `includes/footer.php`, `includes/nav-config.php`, `includes/site-page-hook.php`
- Core JS: `js/inline-editor.js`, `js/image-manager.js`, `js/revision-client.js`, `js/ai-copilot.js`
- All Raiju-Core CSS files (see table above)
- `api/`, `cli/`, `examples/`
- All `*.md` files except site-owned documentation
**Blacklist** (NEVER overwrite on upgrade):
- Everything in `content/` (page JSONs, settings, news, events)
- Everything in `assets/` (images, fonts, audio)
- All language directories (`/de/`, `/en/`, etc.)
- `admin/config.php`
- Custom `includes/header.php`, `includes/footer.php`, `includes/nav-config.php`, `includes/site-page-hook.php`
- Site frontend JS (e.g. `js/main.js`, if present)
- `css/website.css`, `css/fonts.css`, `css/page-*.css`
## Common Mistakes to Avoid
### `editableImage()` returns a full `<img>` tag
Do NOT wrap it in another `<img>`:
```php
<!-- 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:
```php
<!-- 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
```json
// 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
```php
// WRONG
<?php echo renderAllSections('en_your-page'); ?>
// CORRECT
<?php echo renderAllSections($contentPage); ?>
```
## Rules
- **Know which mode you are in before editing.** When adapting a site built with Raiju, avoid modifying Raiju-Core directories such as `admin/`, `includes/`, `api/`, `cli/`, core `css/`, and core `js/`; use content JSON, language/page templates, `css/website.css`, `css/page-*.css`, assets, and documented hooks instead so future Raiju updates stay clean. When working on the Raiju core repository itself, changes to core directories are allowed when the user explicitly asks for core behavior, admin UI, API, CLI, backup, routing, or editor changes.
- **GitHub workflow:** `main` is protected. Push feature/test branches and merge into `main` through a Pull Request; do not expect direct pushes to `main` to succeed.
- **Content goes in JSON** - never hardcode text in templates
- **Use `$basePath`** for all asset and link paths (handles root vs subdirectory serving)
- **BEM class names** + CSS custom properties for all styling
- **No inline styles** except for PHP-injected dynamic values
- **Page-specific styles belong in `css/page-{slug}.css`**, not in `style.css` or `components.css`. Use `$pageStylesheet` in the template (or let the HTML converter generate it). Selectors like `.page-foo .card` in the base stylesheets leak page logic into the CMS core.
- **Every `<img>` needs `alt`**, every interactive element needs `:focus-visible`
- **Set `$contentPage`** before including `header.php` so the admin bar works
- **Visits count once per request.** `includes/header.php` calls `raijuAnalyticsTrack()`;
`route.php` and `index.php` also schedule `raijuAnalyticsScheduleTrack()` as a shutdown
fallback, so a site with a customised header that lacks the call still counts. Whichever runs
first counts; responses with status 300 or above, admins and bots are not visits.
## Concurrent editing and modular core
Core request handlers now live in `admin/api/`; dashboard script fragments live
in `admin/dashboard/scripts/`. Keep their public URLs and action names stable.
Read the “Core modules and shared state” section of `architecture.md` before
changing these boundaries, AI request state or the analytics format.
When calling `save` or `save-settings`, first load the resource through `load` or
`load-settings` and include the returned top-level `revision` in the POST form.
Handle HTTP 409 as a concurrent-edit conflict and HTTP 428 as a missing load.
Never fetch a newer revision solely to force an old snapshot through. The shared
browser revision client already handles normal dashboard and inline-editor forms.