' . "\n";
}
return $html;
}
/**
* Gets a specific value from content
* @param string $page Page name
* @param string $key Key (e.g. 'title')
* @return mixed Value or null
*/
function getContentValue($page, $key) {
$content = loadContent($page);
return $content[$key] ?? null;
}
// ============================================================
// EDITABLE FIELDS FOR CUSTOM LAYOUTS
// ============================================================
/**
* Navigate a nested array using dot notation.
* Example: getNestedValue($data, 'hero.title') returns $data['hero']['title']
*/
function getNestedValue($data, $dotKey) {
$keys = explode('.', $dotKey);
$current = $data;
foreach ($keys as $key) {
if (!is_array($current) || !array_key_exists($key, $current)) {
return null;
}
$current = $current[$key];
}
return $current;
}
/**
* Load content with per-request caching.
* Prevents re-reading JSON on every editableText() call.
*/
function &loadContentCached($page) {
static $cache = [];
// An overlay gets its own cache slot, so setting or changing it never serves stale data.
$key = raijuContentOverlayFor($page) !== null
? $page . '#overlay' . (int)($GLOBALS['raijuContentOverlayVersion'] ?? 0)
: $page;
if (!isset($cache[$key])) {
$cache[$key] = loadContent($page);
}
return $cache[$key];
}
/**
* Set a nested value using dot notation for auto-generation.
*/
function setNestedValue(&$data, $dotKey, $value) {
if (!is_array($data)) $data = [];
$keys = explode('.', $dotKey);
$current = &$data;
foreach ($keys as $i => $key) {
if ($i === count($keys) - 1) {
$current[$key] = $value;
} else {
if (!isset($current[$key]) || !is_array($current[$key])) {
$current[$key] = [];
}
$current = &$current[$key];
}
}
}
/**
* Track fields that were auto-generated during this request.
* Returns the list when called without arguments.
*/
function autoGeneratedFields($page = null, $fieldKey = null) {
static $fields = [];
if ($page === null) return $fields;
$fields[] = ['page' => $page, 'field' => $fieldKey];
}
/**
* Auto-generate a missing field in the JSON file during admin browsing.
* When an editable function encounters a key that doesn't exist in the JSON,
* this writes the fallback value to disk so the Content Editor can see it.
* Tracked via autoGeneratedFields() and surfaced as a toast notification.
*/
function autoGenerateContentField($page, $fieldKey, $value) {
if (empty($page) || empty($fieldKey)) return;
// Scan and preview renders must never write, and an overlay is not what is on disk.
if (raijuContentReadonly() || raijuContentOverlayFor($page) !== null) return;
require_once __DIR__ . '/json-store.php';
$filepath = CONTENT_BASE_PATH . $page . '.json';
$latest = null;
$created = false;
$ok = raijuJsonUpdate($filepath, static function (&$data) use ($page, $fieldKey, $value, &$latest, &$created) {
$cursor = $data;
$exists = true;
foreach (explode('.', $fieldKey) as $key) {
if (!is_array($cursor) || !array_key_exists($key, $cursor)) { $exists = false; break; }
$cursor = $cursor[$key];
}
if (!$exists) {
$data['page'] = $data['page'] ?? $page;
$data['lang'] = $data['lang'] ?? substr($page, 0, 2);
setNestedValue($data, $fieldKey, $value);
$data['lastModified'] = date('c');
$created = true;
}
$latest = $data;
return $created;
});
if ($latest !== null) { $cache = &loadContentCached($page); $cache = $latest; }
if ($ok && $created) autoGeneratedFields($page, $fieldKey);
}
/**
* Check if a field is marked as hidden via a sibling "__hidden" key.
* E.g. for fieldKey "header.title", checks "header.title__hidden".
*/
function isFieldHidden($data, $fieldKey) {
return getNestedValue($data, $fieldKey . '__hidden') === true;
}
/**
* Render an editable plain-text field for custom layouts.
* When admin is logged in: wraps text in a with data attributes.
* For visitors: outputs plain escaped text (or empty string if hidden).
* If the key is missing from JSON and an admin is browsing, the $default
* value is auto-written to the JSON file (see autoGenerateContentField).
*
* @param string $page JSON page name (e.g. 'en_home')
* @param string $fieldKey Dot-notation key (e.g. 'hero.title')
* @param string $default Fallback text — becomes initial JSON value on auto-write
* @return string HTML output
*/
function editableText($page, $fieldKey, $default = '') {
$data = loadContentCached($page);
$value = getNestedValue($data, $fieldKey);
if ($value === null) {
$value = $default;
if (isAdminLoggedIn()) {
autoGenerateContentField($page, $fieldKey, $value);
}
}
$hidden = isFieldHidden($data, $fieldKey);
// Decode HTML entities first (e.g. → → →), then escape for safe output.
// This prevents double-encoding when content contains named entities.
$safeValue = htmlspecialchars(html_entity_decode($value, ENT_QUOTES | ENT_HTML5, 'UTF-8'));
if (isAdminLoggedIn()) {
$hiddenAttr = $hidden ? ' data-hidden="true"' : '';
return ''
. $safeValue
. '';
}
// Hidden fields are not rendered for visitors
if ($hidden) return '';
return $safeValue;
}
/**
* Render an editable rich-text (HTML) field for custom layouts.
* When admin is logged in: wraps content in a
with data attributes.
* For visitors: outputs sanitized HTML.
* If the key is missing from JSON and an admin is browsing, the $default
* value is auto-written to the JSON file (see autoGenerateContentField).
*
* @param string $page JSON page name (e.g. 'en_home')
* @param string $fieldKey Dot-notation key (e.g. 'hero.subtitle')
* @param string $default Fallback HTML — becomes initial JSON value on auto-write
* @return string HTML output
*/
function editableHtml($page, $fieldKey, $default = '') {
$data = loadContentCached($page);
$value = getNestedValue($data, $fieldKey);
if ($value === null) {
$value = $default;
if (isAdminLoggedIn()) {
autoGenerateContentField($page, $fieldKey, $value);
}
}
$hidden = isFieldHidden($data, $fieldKey);
if (isAdminLoggedIn()) {
$hiddenAttr = $hidden ? ' data-hidden="true"' : '';
return '
'
. sanitizeHtml($value)
. '
';
}
if ($hidden) return '';
return sanitizeHtml($value);
}
/**
* Render an editable link/button for custom layouts.
* Stores both text and href in JSON (fieldKey.text and fieldKey.href).
* Admin: adds editable-field-link class + data attributes.
* Visitor: outputs a normal tag.
* If the key is missing from JSON and an admin is browsing, {text, href}
* is auto-written to the JSON file (see autoGenerateContentField).
*
* @param string $page JSON page name
* @param string $fieldKey Dot-notation key (e.g. 'hero.cta1')
* @param string $defaultText Fallback link text — becomes initial JSON value
* @param string $defaultHref Fallback URL — becomes initial JSON value
* @param string $class CSS classes for the tag
* @param string $attrs Extra HTML attributes (e.g. 'target="_blank" rel="noopener"')
* @return string HTML output
*/
function editableLink($page, $fieldKey, $defaultText = '', $defaultHref = '#', $class = '', $attrs = '') {
$data = loadContentCached($page);
$linkData = getNestedValue($data, $fieldKey);
if ($linkData === null) {
$linkData = ['text' => $defaultText, 'href' => $defaultHref];
if (isAdminLoggedIn()) {
autoGenerateContentField($page, $fieldKey, $linkData);
}
}
$text = $defaultText;
$href = $defaultHref;
$target = '';
$download = false;
if (is_array($linkData)) {
$text = $linkData['text'] ?? $defaultText;
$href = $linkData['href'] ?? $defaultHref;
$target = $linkData['target'] ?? '';
$download = !empty($linkData['download']);
} elseif (is_string($linkData)) {
$text = $linkData;
}
// Block javascript: and other dangerous schemes stored in the link target.
$href = raijuSanitizeHref((string)$href);
$hidden = isFieldHidden($data, $fieldKey);
$classAttr = $class ? ' class="' . htmlspecialchars($class) . '"' : '';
$extraAttrs = $attrs ? ' ' . $attrs : '';
$targetAttr = '';
if ($target === '_blank') {
// noopener hardens against reverse tabnabbing on new-tab links
$targetAttr = ' target="_blank" rel="noopener"';
}
$downloadAttr = $download ? ' download' : '';
// Decode HTML entities first (e.g. → → →), then escape for safe output.
// This prevents double-encoding: → would become → without decode.
$safeText = htmlspecialchars(html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'));
if (isAdminLoggedIn()) {
$hiddenAttr = $hidden ? ' data-hidden="true"' : '';
return ''
. $safeText . '';
}
if ($hidden) return '';
return ''
. $safeText . '';
}
/**
* Render an editable image for custom layouts.
* Stores src and alt in JSON (fieldKey.src and fieldKey.alt).
* Admin: adds editable-field-image class + data attributes.
* Visitor: outputs a normal tag.
* If the key is missing from JSON and an admin is browsing, {src, alt}
* is auto-written to the JSON file (see autoGenerateContentField).
*
* @param string $page JSON page name
* @param string $fieldKey Dot-notation key (e.g. 'hero.image')
* @param string $defaultSrc Fallback image path — becomes initial JSON value
* @param string $defaultAlt Fallback alt text — becomes initial JSON value
* @param string $class CSS classes for the tag
* @return string HTML output
*/
function editableImage($page, $fieldKey, $defaultSrc = '', $defaultAlt = '', $class = '') {
$data = loadContentCached($page);
$imgData = getNestedValue($data, $fieldKey);
if ($imgData === null) {
$imgData = ['src' => $defaultSrc, 'alt' => $defaultAlt];
if (isAdminLoggedIn()) {
autoGenerateContentField($page, $fieldKey, $imgData);
}
}
$src = $defaultSrc;
$alt = $defaultAlt;
if (is_array($imgData)) {
$src = $imgData['src'] ?? $defaultSrc;
$alt = $imgData['alt'] ?? $defaultAlt;
} elseif (is_string($imgData)) {
$src = $imgData;
}
$hidden = isFieldHidden($data, $fieldKey);
$classAttr = $class ? ' class="' . htmlspecialchars($class) . '"' : '';
if (isAdminLoggedIn()) {
$hiddenAttr = $hidden ? ' data-hidden="true"' : '';
return ''
. raijuAiLabelHtml((string)$src);
}
if ($hidden) return '';
return ''
. raijuAiLabelHtml((string)$src);
}
/**
* Render an editable image whose src and alt live at two separate
* dot-notation keys (not nested inside a single {src, alt} object).
* Used by block renderers where the JSON schema already stores src
* and alt as sibling fields (e.g. image block: sections.N.src, sections.N.alt).
* Does NOT auto-write on missing keys — schema is expected to exist.
*
* @param string $page JSON page name
* @param string $srcFieldKey Dot-notation key for src (e.g. 'sections.3.src')
* @param string $altFieldKey Dot-notation key for alt (e.g. 'sections.3.alt')
* @param string $defaultSrc Fallback image path
* @param string $defaultAlt Fallback alt text
* @param string $class CSS classes for the tag
* @return string HTML output
*/
function editableImageSplit($page, $srcFieldKey, $altFieldKey, $defaultSrc = '', $defaultAlt = '', $class = '') {
$data = loadContentCached($page);
$src = getNestedValue($data, $srcFieldKey);
$alt = getNestedValue($data, $altFieldKey);
if ($src === null) $src = $defaultSrc;
if ($alt === null) $alt = $defaultAlt;
$hidden = isFieldHidden($data, $srcFieldKey);
$classAttr = $class ? ' class="' . htmlspecialchars($class) . '"' : '';
if (isAdminLoggedIn()) {
$hiddenAttr = $hidden ? ' data-hidden="true"' : '';
return ''
. raijuAiLabelHtml((string)$src);
}
if ($hidden) return '';
return ''
. raijuAiLabelHtml((string)$src);
}
/**
* Render an editable icon token for custom layouts.
* The value is intentionally stored as plain text (e.g. "heart-pulse",
* "check", "icon-service") so templates can map it to their own icon system.
*/
function editableIcon($page, $fieldKey, $default = '', $class = '') {
$data = loadContentCached($page);
$value = getNestedValue($data, $fieldKey);
if ($value === null) {
$value = $default;
if (isAdminLoggedIn()) {
autoGenerateContentField($page, $fieldKey, $value);
}
}
$hidden = isFieldHidden($data, $fieldKey);
$classAttr = trim('editable-field editable-field-icon ' . $class);
$safeValue = htmlspecialchars((string)$value);
if (isAdminLoggedIn()) {
$hiddenAttr = $hidden ? ' data-hidden="true"' : '';
return ''
. $safeValue
. '';
}
if ($hidden) return '';
return '' . $safeValue . '';
}
// ============================================================
// EDITABLE GROUPS (Custom Layout Composite Editors)
// ============================================================
/**
* Return HTML attributes for a custom editable group.
* Use on an existing component wrapper when several editable fields should be
* edited together in one modal (e.g. icon + title + text + link cards).
*
* @param string $page JSON page name (e.g. 'en_home')
* @param string $groupKey Dot-notation base key (e.g. 'features.0')
* @param array $schema ['label' => 'Feature', 'fields' => [...]]
* @return string HTML attributes string or empty for visitors
*/
function editableGroupAttrs($page, $groupKey, $schema = []) {
if (!isAdminLoggedIn()) return '';
if (!raijuGroupSchemaNeedsModal($schema)) {
return '';
}
$schemaJson = json_encode($schema, JSON_UNESCAPED_UNICODE);
if ($schemaJson === false) $schemaJson = '{}';
return ' data-editable-group'
. ' data-page="' . htmlspecialchars($page) . '"'
. ' data-group="' . htmlspecialchars($groupKey) . '"'
. ' data-group-schema="' . htmlspecialchars($schemaJson, ENT_QUOTES, 'UTF-8') . '"';
}
/**
* Keep one-field plain-text groups inline-only. A group modal is worthwhile
* for multiple related fields or any field with a specialized editor.
*/
function raijuGroupSchemaNeedsModal($schema): bool {
if (!is_array($schema)) return false;
$fields = $schema['fields'] ?? [];
if (!is_array($fields) || empty($fields)) return false;
$fieldCount = 0;
$hasSpecial = false;
foreach ($fields as $field) {
if (!is_array($field)) continue;
$key = strtolower((string)($field['key'] ?? $field['path'] ?? ''));
if ($key === '' || $key === 'hidden') continue;
$fieldCount++;
$type = strtolower((string)($field['type'] ?? 'text'));
if (in_array($type, ['image', 'link', 'icon', 'date', 'datetime', 'select', 'checkbox', 'number'], true)) {
$hasSpecial = true;
}
if (preg_match('/^(image|imagesrc|src|photo|portrait|logo|avatar|cover|thumbnail|link|href|url|cta|button|buttonlink|icon|iconid|symbol)$/', $key)) {
$hasSpecial = true;
}
}
return $fieldCount > 1 || $hasSpecial;
}
/**
* Start an admin-only editable group wrapper. The function echoes directly so
* templates can use `` without `echo`.
*/
function editableGroupStart($page, $groupKey, $schema = [], $attrs = '') {
if (!isAdminLoggedIn()) return '';
$extraAttrs = trim($attrs);
echo '
';
return '';
}
/**
* Close an editable group wrapper opened with editableGroupStart().
*/
function editableGroupEnd() {
if (!isAdminLoggedIn()) return '';
echo '
';
return '';
}
/**
* Infer a structured group-editor schema from a list item/default item shape.
* This is intentionally conservative: simple one-field text items stay inline
* only, while cards with multiple fields or media/link/icon/date fields get a
* modal editor that keeps related fields together.
*/
function raijuInferGroupSchemaFromDefaults($defaults, $label = 'Item') {
if (!is_array($defaults)) return [];
$fields = [];
$languageCodes = [];
if (isset($GLOBALS['SITE_LANGUAGES']) && is_array($GLOBALS['SITE_LANGUAGES'])) {
$languageCodes = array_keys($GLOBALS['SITE_LANGUAGES']);
} elseif (defined('SITE_LANG_DEFAULT')) {
$languageCodes = [SITE_LANG_DEFAULT];
}
foreach ($defaults as $key => $value) {
if (!is_string($key) || $key === '' || $key === 'hidden') continue;
$lower = strtolower($key);
$type = 'text';
$localized = false;
if (preg_match('/^(image|imagesrc|src|photo|portrait|logo|avatar|cover|thumbnail)$/', $lower)) {
$type = 'image';
} elseif (preg_match('/^(link|href|url|cta|button|buttonlink)$/', $lower)) {
$type = 'link';
} elseif (preg_match('/^(text|description|desc|intro|body|content|copy|summary)$/', $lower)) {
$type = 'textarea';
} elseif (preg_match('/^(icon|iconid|symbol)$/', $lower)) {
$type = 'icon';
} elseif (preg_match('/^(date|startdate|enddate|time|datetime)$/', $lower)) {
$type = 'text';
}
if (is_array($value)) {
$valueKeys = array_keys($value);
$localized = !empty($languageCodes) && !empty(array_intersect($languageCodes, $valueKeys));
if (array_key_exists('src', $value) || array_key_exists('alt', $value)) {
$type = 'image';
} elseif (array_key_exists('href', $value) || array_key_exists('text', $value)) {
$type = 'link';
}
}
$field = [
'key' => $key,
'type' => $type,
'label' => ucwords(str_replace(['_', '-'], ' ', $key)),
];
if ($localized) {
$field['localized'] = true;
}
$fields[] = $field;
}
$hasSpecialField = false;
foreach ($fields as $field) {
if (in_array($field['type'], ['image', 'link', 'icon'], true)) {
$hasSpecialField = true;
break;
}
}
if (count($fields) < 2 && !$hasSpecialField) {
return [];
}
return [
'label' => $label,
'fields' => $fields,
];
}
/**
* Bind a non-editable element to a content value, so the visual editor refreshes
* it live when that value changes (e.g. image previews or repeated labels that show
* a field edited elsewhere). Images update their src; text and html their content.
* For visitors: returns an empty string.
*
* @param string $page JSON page name (e.g. 'en_home')
* @param string $fieldKey Dot-notation key (e.g. 'features.items.0.image')
* @param string $type 'image', 'text' or 'html'
* @return string HTML attributes string or empty
*/
function contentBindAttrs($page, $fieldKey, $type = 'text') {
if (!isAdminLoggedIn()) return '';
$type = in_array($type, ['image', 'text', 'html'], true) ? $type : 'text';
return ' data-rj-bind="' . $type . '"'
. ' data-rj-bind-page="' . htmlspecialchars($page) . '"'
. ' data-rj-bind-field="' . htmlspecialchars($fieldKey) . '"';
}
/**
* Return list-item attributes plus a structured group-editor schema when the
* item shape warrants a modal. Use this for card-like repeaters so editors get
* both list controls and one modal for related item fields.
*/
function editableListGroupItemAttrs($page, $listKey, $index, $schema = [], $defaults = [], $label = 'Item') {
if (!isAdminLoggedIn()) return '';
if (empty($schema) && !empty($defaults)) {
$schema = raijuInferGroupSchemaFromDefaults($defaults, $label);
}
$attrs = editableListItemAttrs($page, $listKey, $index);
if (!empty($schema)) {
$attrs .= editableGroupAttrs($page, $listKey . '.' . (int)$index, $schema);
}
return $attrs;
}
// ============================================================
// EDITABLE LISTS (Repeatable Items)
// ============================================================
/**
* Return HTML attributes for an editable list container.
* Add these to the element that wraps all list items (e.g. the grid div).
* For visitors: returns empty string.
*
* @param string $page JSON page name (e.g. 'en_home')
* @param string $listKey Dot-notation key to the items object (e.g. 'features.items')
* @param array $defaults Default values for new items (e.g. ['title' => 'New', 'desc' => ''])
* @return string HTML attributes string or empty
*/
function editableListAttrs($page, $listKey, $defaults = []) {
if (!isAdminLoggedIn()) return '';
return ' data-editable-list'
. ' data-list-page="' . htmlspecialchars($page) . '"'
. ' data-list-key="' . htmlspecialchars($listKey) . '"'
. ' data-list-defaults="' . htmlspecialchars(json_encode($defaults, JSON_UNESCAPED_UNICODE)) . '"';
}
/**
* Return HTML attributes for a single editable list item.
* Add these to each repeating element (e.g. each card div).
* For visitors: returns empty string.
*
* @param string $page JSON page name
* @param string $listKey Dot-notation key to the items object
* @param int $index Item index
* @return string HTML attributes string or empty
*/
function editableListItemAttrs($page, $listKey, $index) {
if (!isAdminLoggedIn()) return '';
// Check if list item is hidden
$data = loadContentCached($page);
$items = getNestedValue($data, $listKey);
$hidden = '';
if (is_array($items) && isset($items[$index]) && is_array($items[$index]) && !empty($items[$index]['hidden'])) {
$hidden = ' data-hidden="true"';
}
return ' data-list-page="' . htmlspecialchars($page) . '"'
. ' data-list-key="' . htmlspecialchars($listKey) . '"'
. ' data-list-index="' . (int)$index . '"'
. $hidden;
}
/**
* Get list items from JSON content, reading count dynamically.
* Returns an indexed array of item data, sorted by numeric key.
*
* @param string $page JSON page name
* @param string $listKey Dot-notation key to the items object (e.g. 'features.items')
* @return array Indexed array of item data
*/
function editableListItems($page, $listKey) {
$data = loadContentCached($page);
$items = getNestedValue($data, $listKey);
if (!is_array($items)) {
return [];
}
// Sort by numeric key to ensure correct order
ksort($items, SORT_NUMERIC);
// For visitors, filter out hidden items
if (!isAdminLoggedIn()) {
$items = array_filter($items, function($item) {
return !(is_array($item) && !empty($item['hidden']));
});
// Re-index to maintain sequential keys
$items = array_values($items);
}
return $items;
}
/**
* Render an editable paragraph list — a list of HTML blocks that admins can
* add, remove, reorder, and edit inline (with the floating toolbar).
*
* Uses the existing editable list system internally. Each item is a single
* editableHtml() field with key "{listKey}.{index}.content".
*
* @param string $page Content page identifier (e.g. 'en_about')
* @param string $listKey Dot-notation key to the items object (e.g. 'intro.paragraphs')
* @param array $defaults Default values for new items (default: ['content' => ''])
* @return string HTML output
*/
function editableTextList($page, $listKey, $defaults = ['content' => '']) {
$items = editableListItems($page, $listKey);
$output = '';
if (isAdminLoggedIn()) {
// Admin: wrap in list container with editing attributes
$output .= '