1961 lines
105 KiB
Markdown
1961 lines
105 KiB
Markdown
# 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.
|