<?php
declare(strict_types=1);

/**
 * Template Party CMS - STEP2 最小テンプレート描画ユーティリティ
 * （このファイル単体では出力しません。次ステップで router.php から呼び出します）
 */

//////////////////////////////
// パス関連
//////////////////////////////
// 置き換え【after】
if (!function_exists('tpcms_root_dir')) {
    function tpcms_root_dir(): string {
        // テンプレ側ラッパで TPCMS_ROOT が定義されている場合は、必ずそれを基点にする
        if (defined('TPCMS_ROOT') && is_string(TPCMS_ROOT) && TPCMS_ROOT !== '') {
            return rtrim(TPCMS_ROOT, DIRECTORY_SEPARATOR);
        }
        // 未定義時は従来の _core 基準
        return dirname(__DIR__);
    }
}
if (!function_exists('tpcms_path')) {
    function tpcms_path(string ...$parts): string {
        $p = tpcms_root_dir();
        foreach ($parts as $part) {
            $p .= DIRECTORY_SEPARATOR . ltrim($part, DIRECTORY_SEPARATOR);
        }
        return $p;
    }
}

//////////////////////////////
// JSON 読み込み（存在しない→空配列）
//////////////////////////////
if (!function_exists('tpcms_read_json')) {
    function tpcms_read_json(string $absPath): array {
        if (!is_file($absPath)) return [];
        $json = file_get_contents($absPath);
        if ($json === false) return [];
        $data = json_decode($json, true);
        return is_array($data) ? $data : [];
    }
}

//////////////////////////////
// アクティブテーマ（themes/_active.json）
//////////////////////////////
if (!function_exists('tpcms_active_theme')) {
    function tpcms_active_theme(): array {
        $j = tpcms_read_json(tpcms_path('themes', '_active.json'));
        $theme = isset($j['theme']) && is_string($j['theme']) && $j['theme'] !== '' ? $j['theme'] : 'beginner9';
        $color = isset($j['color']) && is_string($j['color']) && $j['color'] !== '' ? $j['color'] : 'white';
        return ['theme' => $theme, 'color' => $color];
    }
}
// 置き換え【after】
if (!function_exists('tpcms_theme_path')) {
    function tpcms_theme_path(string $theme, string $color, string $fileRel): string {
        // 通常（TPCMS_ROOT 配下）
        $p1 = tpcms_path('themes', $theme, $color, $fileRel);
        if (is_file($p1)) return $p1;

        // 代替（_core の1つ上を基点）— ラッパとコアの基点ズレ保険
        $altRoot = rtrim(dirname(__DIR__), DIRECTORY_SEPARATOR);
        $p2 = $altRoot . DIRECTORY_SEPARATOR . 'themes'
            . DIRECTORY_SEPARATOR . $theme
            . DIRECTORY_SEPARATOR . $color
            . DIRECTORY_SEPARATOR . ltrim($fileRel, DIRECTORY_SEPARATOR);
        return $p2;
    }
}
// 置き換え【after】
if (!function_exists('tpcms_partial_path')) {
    function tpcms_partial_path(string $theme, string $color, string $partialFile): string {
        // 通常（TPCMS_ROOT 配下）
        $p1 = tpcms_path('themes', $theme, $color, 'partials', $partialFile);
        if (is_file($p1)) return $p1;

        // 代替（_core の1つ上を基点）
        $altRoot = rtrim(dirname(__DIR__), DIRECTORY_SEPARATOR);
        $p2 = $altRoot . DIRECTORY_SEPARATOR . 'themes'
            . DIRECTORY_SEPARATOR . $theme
            . DIRECTORY_SEPARATOR . $color
            . DIRECTORY_SEPARATOR . 'partials'
            . DIRECTORY_SEPARATOR . ltrim($partialFile, DIRECTORY_SEPARATOR);
        return $p2;
    }
}

//////////////////////////////
// 安全なエスケープ
//////////////////////////////
if (!function_exists('tpcms_h')) {
    function tpcms_h(?string $s): string {
        return htmlspecialchars((string)($s ?? ''), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
    }
}

//////////////////////////////
// 単純置換・条件・ループ
//////////////////////////////
if (!function_exists('tpcms_is_video_ext')) {
    function tpcms_is_video_ext(string $filename): bool {
        $ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
        return in_array($ext, ['mp4','webm'], true);
    }
}

/**
 * 内部：{{VAR}} を配列から取得（ドット表記未対応の最小版）
 */
if (!function_exists('tpcms_get_var')) {
    function tpcms_get_var(array $data, string $key) {
        return $data[$key] ?? '';
    }
}

/**
 * 内部：スカラ値をテキスト化（*_HTML は生で返す）
 */
if (!function_exists('tpcms_scalar_to_html')) {
    function tpcms_scalar_to_html($value, string $key): string {
        if ($value === null) return '';
        if (is_array($value) || is_object($value)) return '';
        $s = (string)$value;

        // 生HTMLで挿入を許可するキー（*_HTML と同等の扱い）
        static $rawKeys = ['HEADER','FOOTER','NAV','NAV_SUB','CONTENT','FREE_AREA','MAINIMG','CREDIT_HTML','SITE_LOGO_HTML'];
        if (str_ends_with($key, '_HTML') || in_array($key, $rawKeys, true)) {
            return $s; // エスケープしない
        }

        return tpcms_h($s); // それ以外はエスケープ
    }
}

/**
 * ブロック内のテンプレ置換（最小仕様）
 * - {{VAR}}
 * - {{#IF_VAR}}...{{/IF_VAR}}
 * - {{#IF_IMAGE}}...{{/IF_IMAGE}} / {{#IF_VIDEO}}...{{/IF_VIDEO}} （data.FILE を見る）
 * - {{#ROWS}} ... {{/ROWS}} 1段
 */
if (!function_exists('tpcms_apply_template')) {
    function tpcms_apply_template($tpl, $data) {
        $out = (string)$tpl;
        if (!is_array($data)) { $data = (array)$data; }

        // --- TABLE → TABLE_HTML（ブロック前処理） ---
        if (!isset($data['TABLE_HTML']) && isset($data['TABLE']) && is_array($data['TABLE'])) {
            $data['TABLE_HTML'] = tpcms_build_table_html($data['TABLE']);
        }
        // ------------------------------------------------

        // --- THEME_TABLE → THEME_TABLE_HTML（site_sections用：labelは使わない／scopeは出さない／thead.topなら1行目をtheadへ昇格・本文から除外） ---
        if (!isset($data['THEME_TABLE_HTML']) && isset($data['THEME_TABLE']) && is_array($data['THEME_TABLE'])) {
            $def  = $data['THEME_TABLE'];

            // rows/cols 抽出（旧：配列直入れにも対応）
            $cols = (isset($def['cols']) && is_array($def['cols'])) ? $def['cols'] : [];
            if (isset($def['rows']) && is_array($def['rows'])) {
                $rows = $def['rows'];
            } elseif (isset($def['0']) && is_array($def[0])) {
                $rows = $def;
            } else {
                $rows = [];
            }

            // 列キー（colsがあればkey、無ければ先頭行から推定）
            $colKeys = [];
            if ($cols) {
                foreach ($cols as $c) { $colKeys[] = (string)($c['key'] ?? ''); }
            } elseif (!empty($rows) && is_array($rows[0] ?? null)) {
                $colKeys = array_keys($rows[0]);
            }

            $theadTop  = !empty($def['thead']['top']);
            $theadLeft = !empty($def['thead']['left']);

            $h = '';

            // --- thead.top：labelは一切使わず、常に「1行目の値」でtheadを作る（行が無ければ空th）。scope不使用 ---
            $skipFirstRow = false;
            if ($theadTop && $colKeys) {
                $h .= '<thead><tr>';
                if (isset($rows[0]) && is_array($rows[0])) {
                    foreach ($colKeys as $k) {
                        $v = isset($rows[0][$k]) && is_scalar($rows[0][$k]) ? (string)$rows[0][$k] : '';
                        $h .= '<th>' . tpcms_allowlist_inline_html($v) . '</th>';
                    }
                    $skipFirstRow = true; // 1行目はtbodyから除外
                } else {
                    foreach ($colKeys as $_) { $h .= '<th></th>'; }
                }
                $h .= '</tr></thead>';
            }
            // --- /thead.top ---

            // --- tbody（左見出しは thead.left のとき“先頭列のみ th”。scope不使用） ---
            $h .= '<tbody>';
            $rowIndex = 0;
            foreach ($rows as $r) {
                if (!is_array($r)) { $rowIndex++; continue; }
                if ($skipFirstRow && $rowIndex === 0) { $rowIndex++; continue; }

                $h .= '<tr>';
                $i = 0;
                foreach ($colKeys as $key) {
                    $raw = $r[$key] ?? '';
                    $val = is_scalar($raw) ? (string)$raw : '';
                    $val = tpcms_allowlist_inline_html($val);

                    $isLeftHeader = ($theadLeft && $i === 0);
                    $h .= $isLeftHeader ? '<th>'.$val.'</th>' : '<td>'.$val.'</td>';
                    $i++;
                }
                $h .= '</tr>';
                $rowIndex++;
            }
            $h .= '</tbody>';

            // ★ 全セルが空なら空文字にして IF を効かせる（thead.top の見出し行は除外）
            $__hasData = false;
            foreach ($rows as $idx => $r) {
                if (!is_array($r)) continue;
                // thead.top のときは 1 行目はヘッダ行なのでデータ判定から除外
                if (!empty($theadTop) && $idx === 0) continue;

                foreach ($colKeys as $k) {
                    $raw = isset($r[$k]) ? $r[$k] : '';
                    $val = is_scalar($raw) ? (string)$raw : '';
                    // タグ・実体・空白を除去して中身があれば「データあり」
                    $val = trim(html_entity_decode(strip_tags($val), ENT_QUOTES, 'UTF-8'));
                    if ($val !== '') { $__hasData = true; break 2; }
                }
            }
            if (!$__hasData) { $h = ''; }

            $data['THEME_TABLE_HTML'] = $h;
        }


// --- allowlist bootstrap for page_blocks (text/textarea keys for rows recursion) ---
if (!function_exists('tpcms_formdef_parse_from_html')) {
    require_once __DIR__ . '/formdef.php';
}
try {
    // まだ親データに引き継ぎ用の集合が無いときだけ初期化
    if (empty($data['___ALLOW_INLINE']) && empty($data['___ALLOW_NL2BR']) && function_exists('tpcms_formdef_parse_from_html')) {
        $__def = tpcms_formdef_parse_from_html($out);

        $allow = [];   // text/textarea: 許可タグ通す
        $nl2br = [];   // textarea:     改行→<br>（既定どおり）

        $mark = function(array $__f) use (&$allow, &$nl2br) {
            $type = $__f['type'] ?? '';
            if (!isset($__f['key'])) return;
            $k = strtoupper($__f['key']);
            if ($type === 'textarea') { $nl2br[$k] = true; $allow[$k] = true; }
            elseif ($type === 'text') { $allow[$k] = true; }
        };

        // 単発フィールド
        if (!empty($__def['fields']) && is_array($__def['fields'])) {
            foreach ($__def['fields'] as $__f) { $mark($__f); }
        }
        // 繰り返し（ROWSなど）
        if (!empty($__def['repeaters']) && is_array($__def['repeaters'])) {
            foreach ($__def['repeaters'] as $__r) {
                if (empty($__r['fields']) || !is_array($__r['fields'])) continue;
                foreach ($__r['fields'] as $__f) { $mark($__f); }
            }
        }

        // フォールバック
        $nl2br['TEXT'] = true;
        $allow['TEXT'] = true;

        // 再帰引き継ぎ用に親データへ格納（内側でマージされます）
        $data['___ALLOW_INLINE'] = array_keys($allow);
        $data['___ALLOW_NL2BR']  = array_keys($nl2br);
    }
} catch (\Throwable $e) {
    // 解析不能時はスキップ
}
// --- /allowlist bootstrap ---

        // 1) ROWS：各行のデータで再帰描画（行スコープを優先）
        $out = preg_replace_callback('/\{\{#ROWS\}\}(.*?)\{\{\/ROWS\}\}/us', function($m) use ($data) {
            $rows = isset($data['ROWS']) && is_array($data['ROWS']) ? $data['ROWS'] : [];
            if (!$rows) return '';
            $buf = '';
            foreach ($rows as $row) {
                if (!is_array($row)) continue;
                // 親データに行データを上書きして行スコープを作る
                $ctx = $data;
                foreach ($row as $k => $v) { $ctx[$k] = $v; }
                /* 子スコープに許可キー集合を伝播（text/textarea のタグ許可・textarea の nl2br） */
                if (!empty($__allowInlineKeys) || !empty($__nl2brKeys)) {
                    $ctx['___ALLOW_INLINE'] = array_keys($__allowInlineKeys ?? []);
                    $ctx['___ALLOW_NL2BR']  = array_keys($__nl2brKeys ?? []);
                }
                $buf .= tpcms_apply_template($m[1], $ctx);
            }
            return $buf;
        }, $out);

        // 2) IF_IMAGE（キー省略時は FILE）※動画拡張子は除外
        $out = preg_replace_callback('/\{\{#IF_IMAGE(?:\s+([A-Z0-9_]+))?\}\}(.*?)\{\{\/IF_IMAGE\}\}/us', function($m) use ($data) {
            $key = isset($m[1]) && $m[1] !== '' ? $m[1] : 'FILE';
            $val = tpcms_get_var($data, $key);
            $isImage = is_string($val) && $val !== '' && !tpcms_is_video_ext($val);
            return $isImage ? tpcms_apply_template($m[2], $data) : '';
        }, $out);

        // 3) IF_VIDEO（キー省略時は FILE）
        $out = preg_replace_callback('/\{\{#IF_VIDEO(?:\s+([A-Z0-9_]+))?\}\}(.*?)\{\{\/IF_VIDEO\}\}/us', function($m) use ($data) {
            $key = isset($m[1]) && $m[1] !== '' ? $m[1] : 'FILE';
            $val = tpcms_get_var($data, $key);
            $isVideo = is_string($val) && $val !== '' && tpcms_is_video_ext($val);
            return $isVideo ? tpcms_apply_template($m[2], $data) : '';
        }, $out);

        // 4) 汎用 IF（IMAGE/VIDEO は除外して評価）
        $out = preg_replace_callback('/\{\{#IF_(?!IMAGE|VIDEO)([A-Z0-9_]+)\}\}(.*?)\{\{\/IF_\1\}\}/us', function($m) use ($data) {
            $key  = $m[1];
            $body = $m[2];
            $val  = tpcms_get_var($data, $key);
            $show = false;
            if (is_array($val))       $show = !empty($val);
            elseif (is_string($val))  $show = ($val !== '');
            elseif (is_numeric($val)) $show = true;
            elseif (is_bool($val))    $show = $val;
            return $show ? tpcms_apply_template($body, $data) : '';
        }, $out);

// ---------- inline HTML allowlist sanitizer (for text / textarea) ----------
if (!function_exists('tpcms_allowlist_inline_html')) {
    function tpcms_allowlist_inline_html(string $html): string {
        // --- 方針 ---
        // 1) 許可タグ（br,a,strong,span,iframe,img）だけ一時マスク
        // 2) 残りの < と > をすべて実体化（&lt; / &gt;）
        // 3) マスクを戻す
        // 4) 許可タグだけをホワイトリストで整形（属性制限）

        // 1) 許可タグをマスク
        $placeholders = [];
        $pid = 0;
        $html = preg_replace_callback(
            '#</?\s*(a|strong|span|iframe|img|br)\b[^>]*>#i',
            function ($m) use (&$placeholders, &$pid) {
                $token = '%%TAL' . ($pid++) . '%%';
                $placeholders[$token] = $m[0]; // 元のタグ断片を保存
                return $token;
            },
            $html
        );

        // 2) 残りの < > を実体化
        $html = str_replace(['<', '>'], ['&lt;', '&gt;'], $html);

        // 3) マスクを戻す
        if ($placeholders) {
            $html = strtr($html, $placeholders);
        }

        // 4) 許可タグのみ残す
        $html = strip_tags($html, '<br><a><strong><span><iframe><img>');

        // <br> を正規化
        $html = preg_replace('#<\s*br\s*/?\s*>#i', '<br>', $html);

        // <a>タグの属性を許可制で再構築
        $html = preg_replace_callback('#<\s*a\b([^>]*)>(.*?)</a>#is', function ($m) {
            $attrs = tpcms_parse_html_attrs($m[1]);
            $out   = [];

            // class
            if (!empty($attrs['class']) && ($c = tpcms_clean_class($attrs['class'])) !== '') {
                $out[] = 'class="'.$c.'"';
            }
            // href（http/https/メール/電話/相対・アンカー）
            if (!empty($attrs['href'])) {
                $href = trim($attrs['href']);
                if (preg_match('#^(?:https?://|mailto:|tel:|/|\.{1,2}/|\#)[^\s<>"\']+$#u', $href)) {
                    $out[] = 'href="'.htmlspecialchars($href, ENT_QUOTES, 'UTF-8').'"';
                }
            }
            // target
            $target = '';
            if (!empty($attrs['target'])) {
                $t = strtolower($attrs['target']);
                if ($t === '_blank' || $t === '_self') {
                    $target = $t;
                    $out[] = 'target="'.$target.'"';
                }
            }
            // rel
            $relTokens = [];
            if (!empty($attrs['rel'])) {
                $rawRel = strtolower($attrs['rel']);
                foreach (preg_split('/\s+/', $rawRel) as $tok) {
                    if ($tok === '') continue;
                    if (in_array($tok, ['noopener','noreferrer','nofollow','ugc','sponsored'], true)) {
                        $relTokens[$tok] = true;
                    }
                }
            }
            if ($target === '_blank') {
                $relTokens['noopener']   = true;
                $relTokens['noreferrer'] = true;
            }
            if (!empty($relTokens)) {
                $out[] = 'rel="'.implode(' ', array_keys($relTokens)).'"';
            }

            return '<a '.implode(' ', $out).'>'.$m[2].'</a>';
        }, $html);

        // <strong> / <span> は class のみ許可
        $html = preg_replace_callback('#<\s*(strong|span)\b([^>]*)>#is', function ($m) {
            $tag   = strtolower($m[1]);
            $attrs = tpcms_parse_html_attrs($m[2]);
            $out   = [];
            if (!empty($attrs['class']) && ($c = tpcms_clean_class($attrs['class'])) !== '') {
                $out[] = 'class="'.$c.'"';
            }
            return '<'.$tag.( $out ? ' '.implode(' ', $out) : '' ).'>';
        }, $html);

        // <iframe>（https src必須、主要属性のみ）
        $html = preg_replace_callback('#<\s*iframe\b([^>]*)>\s*</iframe>#is', function ($m) {
            $attrs = tpcms_parse_html_attrs($m[1]);
            $out   = [];

            // src（https のみ許可）
            if (!empty($attrs['src'])) {
                $src = trim($attrs['src']);
                if (preg_match('#^https://#i', $src)) {
                    $out[] = 'src="'.htmlspecialchars($src, ENT_QUOTES, 'UTF-8').'"';
                } else {
                    return ''; // 不許可なら除去
                }
            } else {
                return '';
            }
            // 幅・高さ（数値 または %）
            foreach (['width','height'] as $wh) {
                if (!empty($attrs[$wh]) && preg_match('#^([0-9]{1,4}|[0-9]{1,3}%)$#', (string)$attrs[$wh])) {
                    $out[] = $wh.'="'.$attrs[$wh].'"';
                }
            }
            // loading
            if (!empty($attrs['loading'])) {
                $l = strtolower($attrs['loading']);
                if ($l === 'lazy' || $l === 'eager') {
                    $out[] = 'loading="'.$l.'"';
                }
            }
            // referrerpolicy
            if (!empty($attrs['referrerpolicy'])) {
                $rp = strtolower($attrs['referrerpolicy']);
                if (preg_match('#^[a-z\-]{3,40}$#', $rp)) {
                    $out[] = 'referrerpolicy="'.$rp.'"';
                }
            }
            // allowfullscreen
            if (array_key_exists('allowfullscreen', $attrs)) {
                $out[] = 'allowfullscreen';
            }
            // class
            if (!empty($attrs['class']) && ($c = tpcms_clean_class($attrs['class'])) !== '') {
                $out[] = 'class="'.$c.'"';
            }
            return '<iframe '.implode(' ', $out).'></iframe>';
        }, $html);

        // NEW: <img>（https または相対のみ / alt/width/height/loading/class 許可）
        $html = preg_replace_callback('#<\s*img\b([^>]*)\s*/?>#is', function ($m) {
            $attrs = tpcms_parse_html_attrs($m[1]);
            $out   = [];

            // src：https:// or / ./ ../ のみ
            if (!empty($attrs['src'])) {
                $src = trim($attrs['src']);
                if (preg_match('#^(https://|/|\./|\.\./)#i', $src)) {
                    $out[] = 'src="'.htmlspecialchars($src, ENT_QUOTES, 'UTF-8').'"';
                } else {
                    return ''; // 不許可なら除去
                }
            } else {
                return '';
            }

            // alt
            if (array_key_exists('alt', $attrs)) {
                $out[] = 'alt="'.htmlspecialchars($attrs['alt'], ENT_QUOTES, 'UTF-8').'"';
            } else {
                $out[] = 'alt=""';
            }

            // width/height（数値のみ）
            foreach (['width','height'] as $wh) {
                if (!empty($attrs[$wh]) && preg_match('#^[0-9]{1,4}$#', (string)$attrs[$wh])) {
                    $out[] = $wh.'="'.$attrs[$wh].'"';
                }
            }

            // loading（lazyのみ許可）
            if (!empty($attrs['loading'])) {
                $l = strtolower($attrs['loading']);
                if ($l === 'lazy') {
                    $out[] = 'loading="lazy"';
                }
            }

            // class
            if (!empty($attrs['class']) && ($c = tpcms_clean_class($attrs['class'])) !== '') {
                $out[] = 'class="'.$c.'"';
            }

            return '<img '.implode(' ', $out).'>';
        }, $html);

        return $html;
    }
}

if (!function_exists('tpcms_parse_html_attrs')) {
    function tpcms_parse_html_attrs(string $attrStr): array {
        $attrs = [];
        if ($attrStr === '') return $attrs;

        // name="..." / '...' / 値なし をゆるくパース
        $pattern = '#([a-zA-Z0-9:_-]+)\s*(=\s*(?:"([^"]*)"|\'([^\']*)\'|([^\s"\'=<>`]+)))?#';
        if (preg_match_all($pattern, $attrStr, $m, PREG_SET_ORDER)) {
            foreach ($m as $a) {
                $name = strtolower($a[1]);
                $val  = '';
                if (isset($a[3]) && $a[3] !== '') $val = $a[3];
                elseif (isset($a[4]) && $a[4] !== '') $val = $a[4];
                elseif (isset($a[5]) && $a[5] !== '') $val = $a[5];
                $attrs[$name] = $val;
            }
        }
        // イベント属性 / style は無効化
        unset($attrs['style']);
        foreach ($attrs as $k => $v) {
            if (strpos($k, 'on') === 0) unset($attrs[$k]);
        }
        return $attrs;
    }
}

if (!function_exists('tpcms_clean_class')) {
    function tpcms_clean_class(string $cls): string {
        // 英数・アンダースコア・ハイフン・半角スペースのみ、空白は1つに正規化
        $cls = preg_replace('/[^a-zA-Z0-9 _\-]/', ' ', $cls);
        $cls = preg_replace('/\s+/', ' ', $cls);
        return trim($cls);
    }
}
// -------------------------------------------------------------------------

// ---------- menu label allowlist sanitizer (br と span のみ許可) ----------
if (!function_exists('tpcms_allowlist_menu_label')) {
    function tpcms_allowlist_menu_label(string $s): string {
        // 許可タグ
        $s = strip_tags($s, '<br><span><i><h3>');

        // <br> を正規化
        $s = preg_replace('#<\s*br\s*/?\s*>#i', '<br>', $s);

        // <span> は class のみ許可（style/on* は不可）
        $s = preg_replace_callback('#<\s*span\b([^>]*)>#is', function ($m) {
            if (!function_exists('tpcms_parse_html_attrs')) return '<span>';
            if (!function_exists('tpcms_clean_class'))     return '<span>';
            $attrs = tpcms_parse_html_attrs($m[1]);
            $out   = [];
            if (!empty($attrs['class']) && ($c = tpcms_clean_class($attrs['class'])) !== '') {
                $out[] = 'class="'.$c.'"';
            }
            return '<span'.($out ? ' '.implode(' ', $out) : '').'>';
        }, $s);

        // <i> は class のみ許可（Font Awesome 等のアイコン用途）
        $s = preg_replace_callback('#<\s*i\b([^>]*)>#is', function ($m) {
            if (!function_exists('tpcms_parse_html_attrs')) return '<i>';
            if (!function_exists('tpcms_clean_class'))     return '<i>';
            $attrs = tpcms_parse_html_attrs($m[1]);
            $out   = [];
            if (!empty($attrs['class']) && ($c = tpcms_clean_class($attrs['class'])) !== '') {
                $out[] = 'class="'.$c.'"';
            }
            return '<i'.($out ? ' '.implode(' ', $out) : '').'>';
        }, $s);

        // <h3> は class のみ許可（見出し用）
        $s = preg_replace_callback('#<\s*h3\b([^>]*)>#is', function ($m) {
            if (!function_exists('tpcms_parse_html_attrs')) return '<h3>';
            if (!function_exists('tpcms_clean_class'))     return '<h3>';
            $attrs = tpcms_parse_html_attrs($m[1]);
            $out   = [];
            if (!empty($attrs['class']) && ($c = tpcms_clean_class($attrs['class'])) !== '') {
                $out[] = 'class="'.$c.'"';
            }
            return '<h3'.($out ? ' '.implode(' ', $out) : '').'>';
        }, $s);

        return $s;
    }
}
// -------------------------------------------------------------------------

        // --- {{KEY_HTML}} : 配列 → 安全トークン結合（英数/_/- のみ）を“生”で挿入 ---
        // ※ THEME_* 系は除外。非配列は従来どおり *_HTML（生挿入）の扱い。
        $out = preg_replace_callback('/\{\{(?!THEME_)([A-Z0-9_]+)(?<!_SPANS)_HTML\}\}/u', function ($m) use ($data) {
            $base = $m[1];
            $val  = tpcms_get_var($data, $base);
            $direct = tpcms_get_var($data, $base.'_HTML');
            if ($direct !== '' && !is_array($direct)) {
                return tpcms_scalar_to_html($direct, $base.'_HTML');
            }

            if (is_array($val)) {
                $tokens = [];
                foreach ($val as $v) {
                    if (is_array($v) || is_object($v)) continue;
                    $s = (string)$v;

                    $s = preg_replace('/[^A-Za-z0-9_\-\s]+/u', ' ', $s);
                    $s = preg_replace('/\s+/u', ' ', $s);
                    $s = trim($s);
                    if ($s !== '') $tokens[] = $s;
                }
                return implode(' ', $tokens);
            }

            // 非配列：base が無ければ 直接の *_HTML を優先
            $direct = tpcms_get_var($data, $base.'_HTML');
            return tpcms_scalar_to_html(($direct !== '' && !is_array($direct)) ? $direct : $val, $base.'_HTML');
        }, $out);

        // --- {{KEY_SPANS_HTML}} : optionsラベル付きで <span class="token">LABEL</span> を連結して出力 ---
        // 例）{{ICONS_SPANS_HTML}} → <span class="icon1">男の子</span><span class="icon2">女の子</span>
        // ※ THEME_* は対象外。まず “この部分テンプレ名” から定義を読み、無ければ HTML から解析。
        $out = preg_replace_callback('/\{\{(?!THEME_)([A-Z0-9_]+)_SPANS_HTML\}\}/u', function ($m) use ($data, $out) {
            $base = $m[1];
            $val  = tpcms_get_var($data, $base);

            // 値を配列に正規化
            $vals = [];
            if (is_array($val)) {
                foreach ($val as $v) {
                    if (is_array($v) || is_object($v)) continue;
                    $s = (string)$v;
                    if ($s === '') continue;
                    $vals[] = $s;
                }
            } elseif (is_string($val) && $val !== '') {
                $vals[] = $val;
            } else {
                return '';
            }

            // options の value=>label マップを構築
            if (!function_exists('tpcms_formdef_parse_from_html') || !function_exists('tpcms_formdef_load_by_name')) {
                require_once __DIR__ . '/formdef.php';
            }
            $labelMap = [];

            // 1) 部品名（___PARTIAL_NAME）から定義を取得（最優先）
            $partName = isset($data['___PARTIAL_NAME']) && is_string($data['___PARTIAL_NAME']) ? $data['___PARTIAL_NAME'] : '';
            if ($partName !== '' && function_exists('tpcms_formdef_load_by_name')) {
                try {
                    $def = tpcms_formdef_load_by_name($partName);

                    // 単項目 fields
                    if (!empty($def['fields']) && is_array($def['fields'])) {
                        foreach ($def['fields'] as $f) {
                            if (!is_array($f)) continue;
                            $k = isset($f['key']) ? strtoupper((string)$f['key']) : '';
                            if ($k === strtoupper($base) && !empty($f['options']) && is_array($f['options'])) {
                                foreach ($f['options'] as $op) {
                                    $v = (string)($op[0] ?? '');
                                    $l = (string)($op[1] ?? $v);
                                    $labelMap[$v] = $l;
                                }
                            }
                        }
                    }
                    // 繰り返し fields
                    if (empty($labelMap) && !empty($def['repeaters']) && is_array($def['repeaters'])) {
                        foreach ($def['repeaters'] as $rep) {
                            if (empty($rep['fields']) || !is_array($rep['fields'])) continue;
                            foreach ($rep['fields'] as $f) {
                                if (!is_array($f)) continue;
                                $k = isset($f['key']) ? strtoupper((string)$f['key']) : '';
                                if ($k === strtoupper($base) && !empty($f['options']) && is_array($f['options'])) {
                                    foreach ($f['options'] as $op) {
                                        $v = (string)($op[0] ?? '');
                                        $l = (string)($op[1] ?? $v);
                                        $labelMap[$v] = $l;
                                    }
                                }
                            }
                        }
                    }
                } catch (\Throwable $e) { /* noop */ }
            }

            // 2) フォールバック：このテンプレ文字列($out)から解析
            if (empty($labelMap) && function_exists('tpcms_formdef_parse_from_html')) {
                try {
                    $def = tpcms_formdef_parse_from_html($out);

                    if (!empty($def['fields']) && is_array($def['fields'])) {
                        foreach ($def['fields'] as $f) {
                            if (!is_array($f)) continue;
                            $k = isset($f['key']) ? strtoupper((string)$f['key']) : '';
                            if ($k === strtoupper($base) && !empty($f['options']) && is_array($f['options'])) {
                                foreach ($f['options'] as $op) {
                                    $v = (string)($op[0] ?? '');
                                    $l = (string)($op[1] ?? $v);
                                    $labelMap[$v] = $l;
                                }
                            }
                        }
                    }
                    if (empty($labelMap) && !empty($def['repeaters']) && is_array($def['repeaters'])) {
                        foreach ($def['repeaters'] as $rep) {
                            if (empty($rep['fields']) || !is_array($rep['fields'])) continue;
                            foreach ($rep['fields'] as $f) {
                                if (!is_array($f)) continue;
                                $k = isset($f['key']) ? strtoupper((string)$f['key']) : '';
                                if ($k === strtoupper($base) && !empty($f['options']) && is_array($f['options'])) {
                                    foreach ($f['options'] as $op) {
                                        $v = (string)($op[0] ?? '');
                                        $l = (string)($op[1] ?? $v);
                                        $labelMap[$v] = $l;
                                    }
                                }
                            }
                        }
                    }
                } catch (\Throwable $e) { /* noop */ }
            }

            // クラス用トークン正規化（英数/_/- のみ）
            $normalize = function (string $s): string {
                $s = preg_replace('/[^A-Za-z0-9_\-\s]+/u', ' ', $s);
                $s = preg_replace('/\s+/u', ' ', $s);
                return trim($s);
            };

            // 生成
            $buf = '';
            foreach ($vals as $v) {
                $tok = $normalize($v);
                if ($tok === '') continue;
                $label = isset($labelMap[$v]) ? $labelMap[$v] : $v; // ラベル未取得時は値を表示
                $buf .= '<span class="'.tpcms_h($tok).'">'.tpcms_h($label).'</span>';
            }
            
            return $buf;
        }, $out);

// 5) 単純変数 {{VAR}}（text/textareaのみ許可タグ、textareaは改行→<br>）
// ---------------------------------------------------------------------
$__nl2brKeys = [];          // textarea 用（改行→<br>）
$__allowInlineKeys = [];    // text/textarea 用（許可タグ通す）

// A) テーマ共通/専用（site.php）: theme.formdef.json から text / textarea を収集
try {
    $activeJson = @file_get_contents(tpcms_path('themes', '_active.json'));
    if ($activeJson !== false && ($__act = json_decode($activeJson, true))) {
        $formdefPath = tpcms_path('themes', ($__act['theme'] ?? ''), ($__act['color'] ?? ''), 'theme.formdef.json');
        if (is_file($formdefPath)) {
            if (($__fd = json_decode(file_get_contents($formdefPath), true))) {
                // 共通: site_fields
                if (!empty($__fd['site_fields']) && is_array($__fd['site_fields'])) {
                    foreach ($__fd['site_fields'] as $__f) {
                        $type = $__f['type'] ?? '';
                        $k    = isset($__f['key']) ? strtoupper($__f['key']) : null;
                        if (!$k) continue;
                        if ($type === 'textarea') {
                            $__nl2brKeys['THEME_'.$k]       = true;
                            $__allowInlineKeys['THEME_'.$k] = true;
                        } elseif ($type === 'text') {
                            $__allowInlineKeys['THEME_'.$k] = true;
                        }
                    }
                }
                // テーマ専用: site_sections
                if (!empty($__fd['site_sections']) && is_array($__fd['site_sections'])) {
                    foreach ($__fd['site_sections'] as $__sec) {
                        $secKey = strtoupper($__sec['key'] ?? '');
                        if (empty($__sec['fields']) || !is_array($__sec['fields'])) continue;

                        foreach ($__sec['fields'] as $__f) {
                            $type = $__f['type'] ?? '';
                            $fKey = isset($__f['key']) ? strtoupper($__f['key']) : null;
                            if (!$fKey) continue;

                            if ($type === 'textarea') {
                                // THEME_ 付き
                                $__nl2brKeys['THEME_'.$fKey]       = true;
                                $__allowInlineKeys['THEME_'.$fKey] = true;
                                if ($secKey !== '') {
                                    $__nl2brKeys['THEME_'.$secKey.'_'.$fKey]       = true;
                                    $__allowInlineKeys['THEME_'.$secKey.'_'.$fKey] = true;
                                }
                                // （追加）テンプレ側が THEME_ 無しで {{VAR}} を使っている場合にも対応
                                $__nl2brKeys[$fKey]       = true;
                                $__allowInlineKeys[$fKey] = true;
                                if ($secKey !== '') {
                                    $__nl2brKeys[$secKey.'_'.$fKey]       = true;
                                    $__allowInlineKeys[$secKey.'_'.$fKey] = true;
                                }
                            } elseif ($type === 'text') {
                                // text は許可タグのみ（改行変換はしない）
                                $__allowInlineKeys['THEME_'.$fKey] = true;
                                if ($secKey !== '') {
                                    $__allowInlineKeys['THEME_'.$secKey.'_'.$fKey] = true;
                                }
                                // 非プレフィックスも許容
                                $__allowInlineKeys[$fKey] = true;
                                if ($secKey !== '') {
                                    $__allowInlineKeys[$secKey.'_'.$fKey] = true;
                                }
                            }
                        }
                    }
                }
            }
        }
    }
} catch (\Throwable $e) {
    // 読み取れない場合はスキップ
}

// C) フォールバック: *_TEXT は textarea 相当として扱う（nl2br 対象）
$__nl2brKeys['TEXT']       = true;
$__allowInlineKeys['TEXT'] = true;

/* 親スコープからの伝播（ROWS 再帰用） */
if (!empty($data['___ALLOW_INLINE']) && is_array($data['___ALLOW_INLINE'])) {
    foreach ($data['___ALLOW_INLINE'] as $__k) {
        $__allowInlineKeys[strtoupper((string)$__k)] = true;
    }
}
if (!empty($data['___ALLOW_NL2BR']) && is_array($data['___ALLOW_NL2BR'])) {
    foreach ($data['___ALLOW_NL2BR'] as $__k) {
        $__nl2brKeys[strtoupper((string)$__k)] = true;
    }
}

        $out = preg_replace_callback('/\{\{([A-Z0-9_]+)\}\}/u', function ($m) use ($data, $__nl2brKeys, $__allowInlineKeys, $out) {
            $key = $m[1];

            // --- 専用：{{KEY_SPANS_HTML}} をラベル付きで <span class="token">LABEL</span> 群に展開 ---
            if (strlen($key) > 11 && substr($key, -11) === '_SPANS_HTML') {
                $base = substr($key, 0, -11);
                $val  = tpcms_get_var($data, $base);

                // 値を配列に正規化
                $vals = [];
                if (is_array($val)) {
                    foreach ($val as $v) {
                        if (is_array($v) || is_object($v)) continue;
                        $s = (string)$v;
                        if ($s === '') continue;
                        $vals[] = $s;
                    }
                } elseif (is_string($val) && $val !== '') {
                    $vals[] = $val;
                } else {
                    return '';
                }

                // options の value=>label マップをテンプレ内の tpcms-form コメントから取得（失敗時は値をそのままラベルに）
                $labelMap = [];
                if (!function_exists('tpcms_formdef_parse_from_html')) {
                    require_once __DIR__ . '/formdef.php';
                }
                try {
                    if (function_exists('tpcms_formdef_parse_from_html')) {
                        $def = tpcms_formdef_parse_from_html($out);

                        // 単項目 fields
                        if (!empty($def['fields']) && is_array($def['fields'])) {
                            foreach ($def['fields'] as $f) {
                                if (!is_array($f)) continue;
                                $k = isset($f['key']) ? strtoupper((string)$f['key']) : '';
                                if ($k === strtoupper($base) && !empty($f['options']) && is_array($f['options'])) {
                                    foreach ($f['options'] as $op) {
                                        $v = (string)($op[0] ?? '');
                                        $l = (string)($op[1] ?? $v);
                                        $labelMap[$v] = $l;
                                    }
                                }
                            }
                        }
                        // ROWS 内 fields
                        if (empty($labelMap) && !empty($def['repeaters']) && is_array($def['repeaters'])) {
                            foreach ($def['repeaters'] as $rep) {
                                if (empty($rep['fields']) || !is_array($rep['fields'])) continue;
                                foreach ($rep['fields'] as $f) {
                                    if (!is_array($f)) continue;
                                    $k = isset($f['key']) ? strtoupper((string)$f['key']) : '';
                                    if ($k === strtoupper($base) && !empty($f['options']) && is_array($f['options'])) {
                                        foreach ($f['options'] as $op) {
                                            $v = (string)($op[0] ?? '');
                                            $l = (string)($op[1] ?? $v);
                                            $labelMap[$v] = $l;
                                        }
                                    }
                                }
                            }
                        }
                    }
                } catch (\Throwable $e) {
                    // パースに失敗してもフォールバックで続行
                }

                // クラストークン正規化（英数/_/- のみ）
                $normalize = function (string $s): string {
                    $s = preg_replace('/[^A-Za-z0-9_\-]+/u', '-', $s);
                    $s = preg_replace('/-+/', '-', $s);
                    return trim($s, "- \t\n\r\0\x0B");
                };

                $buf = '';
                foreach ($vals as $v) {
                    $tok = $normalize($v);
                    if ($tok === '') continue;
                    $label = isset($labelMap[$v]) ? $labelMap[$v] : $v;
                    $buf .= '<span class="'.tpcms_h($tok).'">'.tpcms_h($label).'</span>';
                }
                return $buf;
            }

            // --- （従来どおり）通常キーの処理 ---
            $raw = tpcms_get_var($data, $key);

            // textarea: 許可タグ → 改行を <br>（1改行=1つ）
            if (!empty($__nl2brKeys[$key]) || (strlen($key) > 5 && substr($key, -5) === '_TEXT')) {
                $san = tpcms_allowlist_inline_html((string)$raw);
                return str_replace(["\r\n", "\r", "\n"], "<br>", $san);
            }

            // 1行テキスト: 許可タグのみ通す（改行変換はしない）
            if (!empty($__allowInlineKeys[$key])) {
                return tpcms_allowlist_inline_html((string)$raw);
            }

            // それ以外: 既存仕様（*_HTMLは生、それ以外はエスケープ等）
            return tpcms_scalar_to_html($raw, $key);
        }, $out);


/* ---------- THEME_* (site_sections) の table を *_HTML に変換（テンプレ置換） ---------- */
if (isset($site) && is_array($site)) {
    // rows配列 → HTML <table> へ簡易変換（labels未取得時はキー名を見出しにする）
$buildTableHtml = function ($rows) {
    if (!is_array($rows) || !isset($rows[0]) || !is_array($rows[0])) return '';
    $cols = array_keys($rows[0]);

    // thead は出さず、tbody のみ返す
    $h = '<tbody>';
    foreach ($rows as $r) {
        if (!is_array($r)) continue;
        $h .= '<tr>';
        foreach ($cols as $ck) {
            $cell = isset($r[$ck]) && is_scalar($r[$ck]) ? (string)$r[$ck] : '';
            $h .= '<td>' . htmlspecialchars($cell, ENT_QUOTES, 'UTF-8') . '</td>';
        }
        $h .= '</tr>';
    }
    $h .= '</tbody>';

    return $h;
};

    // 形式：{{THEME_<SECTION>_<KEY>_HTML}} を置換
    $out = preg_replace_callback('/\{\{THEME_([A-Z0-9_]+)_([A-Z0-9_]+)_HTML\}\}/', function ($m) use ($site, $buildTableHtml) {
        $sec = 'THEME_' . $m[1];   // 例：MAINIMG → THEME_MAINIMG
        $key = $m[2];              // 例：THEME_TABLE
        $rows = $site[$sec][$key] ?? null;
        return $buildTableHtml($rows);
    }, $out);

    // 後方互換（セクション省略形）：{{THEME_<KEY>_HTML}} → 最初に見つかったセクションの同キーを採用
    $out = preg_replace_callback('/\{\{THEME_([A-Z0-9_]+)_HTML\}\}/', function ($m) use ($site, $buildTableHtml) {
        $key = $m[1]; // 例：THEME_TABLE
        foreach ($site as $sec => $vals) {
            if (strpos($sec, 'THEME_') === 0 && is_array($vals) && isset($vals[$key])) {
                return $buildTableHtml($vals[$key]);
            }
        }
        return '';
    }, $out);
}
/* ---------- /THEME_* table → *_HTML ---------- */
        $out = preg_replace('~<!--\s*tpcms-form\s*:\s*\{.*?\}\s*-->~is', '', (string)$out);
        return $out;
    }
}


//////////////////////////////
// partial 読み込み＆描画（存在しない→スキップ）
//////////////////////////////
if (!function_exists('tpcms_render_block')) {
    function tpcms_render_block(string $theme, string $color, array $block): string {
        $partial = (string)($block['partial'] ?? '');
        $data    = is_array($block['data'] ?? null) ? $block['data'] : [];

        if ($partial === '') return '';
        $path = tpcms_partial_path($theme, $color, $partial);
        if (!is_file($path)) {
            error_log("[tpcms] missing partial: {$partial}");
            return '';
        }
        $tpl = file_get_contents($path);
        if ($tpl === false) return '';
        // 部品名を子テンプレに渡す（SPANS 用のラベル解決に使用）
        $data['___PARTIAL_NAME'] = $partial;
        return tpcms_apply_template($tpl, $data);
    }
}

if (!function_exists('tpcms_render_blocks')) {
    function tpcms_render_blocks(string $theme, string $color, array $blocks): string {
        $html = '';
        foreach ($blocks as $b) {
            if (!is_array($b)) continue;
            $html .= tpcms_render_block($theme, $color, $b);
        }
        return $html;
    }
}


//////////////////////////////
// NAV 生成（最小 ul/li）
//////////////////////////////
if (!function_exists('tpcms_build_nav')) {
    function tpcms_build_nav(array $menu, string $currentSlug = ''): string {
        $items = $menu['items'] ?? [];
        if (!is_array($items) || empty($items)) return '';

        // 親子判定：slug の "--" で親（左）/子（右）を判定
        $parents  = [];            // 親を挿入順で保持（slug => data）
        $children = [];            // 親slug => [子...]
        foreach ($items as $it) {
            if (!is_array($it)) continue;
            $slug  = (string)($it['slug']  ?? '');
            $label = (string)($it['label'] ?? $slug);
            $show  = (int)($it['show'] ?? 1);
            if ($show !== 1) continue;
            $isHeading = (stripos($label, '<h3') !== false);
            if ($isHeading && $slug === '') {
                $parents[] = ['slug' => '', 'label' => $label, 'heading' => true];
                continue;
            }
if ($slug === '' || $label === '') continue;

            $pos = strpos($slug, '--');
            if ($pos === false) {
                // 親
                $parents[$slug] = ['slug' => $slug, 'label' => $label];
            } else {
                // 子（最初の "--" より左が親slug）
                $parent = substr($slug, 0, $pos);
                $children[$parent][] = ['slug' => $slug, 'label' => $label];
            }
        }

        // 出力：トップレベルは <li> 群のみ（外側<ul>なし）
        $html = '';
        foreach ($parents as $pSlug => $p) {
            if (!empty($p['heading'])) {
                $html .= '<li class="nav-heading">' . tpcms_allowlist_menu_label((string)($p['label'] ?? '')) . '</li>';
                continue;
            }
    if ($pSlug !== '' && $pSlug[0] === '#') {
                // アンカーは、index では "#...", それ以外のページでは "./#..."
                $href = ($currentSlug === 'index') ? $pSlug : './' . $pSlug;
            } elseif (strpos($pSlug, 'http://') === 0 || strpos($pSlug, 'https://') === 0) {
                // 外部リンクはそのまま
                $href = $pSlug;
            } else {
                // 通常ページ
                $href = ($pSlug === 'index') ? './' : './' . $pSlug;
            }

            // 現在ページ判定：親自身 or 親配下の子（"parent--" で始まる）
            $isCurrentParent = ($currentSlug === $pSlug)
                            || ($currentSlug !== '' && str_starts_with($currentSlug, $pSlug . '--'));

            $html .= '<li' . ($isCurrentParent ? ' class="current"' : '') . '>';
            $html .= '<a href="' . tpcms_h($href) . '">' . tpcms_allowlist_menu_label((string)($p['label'] ?? '')) . '</a>';

            if (!empty($children[$pSlug])) {
                $html .= '<ul>';
                foreach ($children[$pSlug] as $c) {
                    if (!empty($c['slug']) && $c['slug'][0] === '#') {
                        // アンカーは、index では "#...", それ以外のページでは "./#..."
                        $href2 = ($currentSlug === 'index') ? $c['slug'] : './' . $c['slug'];
                    } elseif (!empty($c['slug']) && (strpos($c['slug'], 'http://') === 0 || strpos($c['slug'], 'https://') === 0)) {
                        // 外部リンクはそのまま
                        $href2 = $c['slug'];
                    } else {
                        // 通常ページ
                        $href2 = ($c['slug'] === 'index') ? './' : './' . $c['slug'];
                    }
                    $isCurrentChild = ($currentSlug === $c['slug']);
                    $html .= '<li' . ($isCurrentChild ? ' class="current"' : '') . '>';
                    $html .= '<a href="' . tpcms_h($href2) . '">' . tpcms_allowlist_menu_label((string)($c['label'] ?? '')) . '</a></li>';
                }
                $html .= '</ul>';
            }
            $html .= '</li>';
        }
        return $html;
    }
}


// ////////////////////////////
// TABLE: 定義からHTMLを生成（cols欠落時のフォールバック対応）
// ////////////////////////////
if (!function_exists('tpcms_build_table_html')) {
    /**
     * TABLE 定義（cols/rows/thead）から HTML を生成して返す。
     * - thead.top  : 最上段に列ヘッダー
     * - thead.left : 左端列を行ヘッダー（is_th=true の列、または cols 未定義時は先頭セル）
     * - cols が空でも、rows の先頭行から列キーを推定して出力（フォールバック）
     * @param array $table
     * @return string
     */
function tpcms_build_table_html(array $def): string
{
    // 入力の標準化
    $rows = [];
    $cols = [];
    if (isset($def['rows']) && is_array($def['rows'])) {
        $rows = $def['rows'];
    } elseif (isset($def[0]) && is_array($def[0])) {
        // 旧形式：rows ラッパー無しで配列直入れ
        $rows = $def;
    }
    if (isset($def['cols']) && is_array($def['cols'])) {
        $cols = $def['cols'];
    }

    $theadTop  = !empty($def['thead']['top']);
    $theadLeft = !empty($def['thead']['left']);

    // 列キーを決定
    $colKeys = [];
    if ($cols) {
        foreach ($cols as $c) {
            $colKeys[] = (string)($c['key'] ?? '');
        }
    } elseif (isset($rows[0]) && is_array($rows[0])) {
        $colKeys = array_keys($rows[0]);
    }

    $h = '';

    // --- thead.top 処理（scope は出さない／label は使わない：常に「1行目を昇格」） ---
    $skipFirstRow = false;
    if ($theadTop && $colKeys) {
        $h .= '<thead><tr>';
        if (isset($rows[0]) && is_array($rows[0])) {
            // 1行目の値で thead を作り、その行は本文から除外
            foreach ($colKeys as $k) {
                $v = isset($rows[0][$k]) && is_scalar($rows[0][$k]) ? (string)$rows[0][$k] : '';
                $h .= '<th>' . tpcms_allowlist_inline_html($v) . '</th>';
            }
            $skipFirstRow = true;
        } else {
            // 行が無い場合は、列数ぶんの空ヘッダを出す
            foreach ($colKeys as $_) {
                $h .= '<th></th>';
            }
        }
        $h .= '</tr></thead>';
    }
    // --- /thead.top ---

    // --- tbody 出力（先頭行を thead に昇格した場合のみ除外／label は一切参照しない）---
    $h .= '<tbody>';
    $rowIndex = 0;
    foreach ($rows as $r) {
        if (!is_array($r)) { $rowIndex++; continue; }
        if ($skipFirstRow && $rowIndex === 0) { $rowIndex++; continue; } // 先頭行を thead に使った場合だけスキップ

        $h .= '<tr>';
        $i = 0;
        foreach ($colKeys as $k) {
            $raw = $r[$k] ?? '';
            $val = is_scalar($raw) ? (string)$raw : '';
            $val = tpcms_allowlist_inline_html($val);

            // 左見出し：thead.left が true のとき “先頭列のみ th”
            $isLeftHeader = ($theadLeft && $i === 0);
            $h .= $isLeftHeader ? '<th>'.$val.'</th>' : '<td>'.$val.'</td>';
            $i++;
        }
        $h .= '</tr>';

        $rowIndex++;
    }
    $h .= '</tbody>';

    return $h;
}

}


if (!function_exists('tpcms_build_nav_sub')) {
    /**
     * NAV_SUB 生成：menu.items から show_sub==1 のみを抽出し、
     * 既存の tpcms_build_nav を使って HTML を生成します。
     * - show_sub 未定義は 1（表示）として扱う（後方互換）
     * - main の show とは独立（show=0 でも sub=1 なら出力）
     *   └ tpcms_build_nav の内部フィルタを通すため、ここで show=1 を強制
     */
    function tpcms_build_nav_sub(array $menu, string $currentSlug = ''): string {
        $items = is_array($menu['items'] ?? null) ? $menu['items'] : [];
        if (empty($items)) return '';
        $filtered = [];
        foreach ($items as $it) {
            if (!is_array($it)) continue;
            $showSub = (int)($it['show_sub'] ?? 1);
            if ($showSub !== 1) continue; // 未定義は1扱い、0のみ除外
            $it2 = $it;
            $it2['show'] = 1; // ← ここで main側のshowフィルタを回避
            $filtered[] = $it2;
        }
        return tpcms_build_nav(['items' => $filtered], $currentSlug);
    }
}


//////////////////////////////
// レイアウトの読み込み（index/page）と共通置換
//////////////////////////////
if (!function_exists('tpcms_render_page')) {
    /**
     * @param string $slug  ページ識別子（index なら index.html レイアウト）
     * @param array  $page  /data/themes/<theme>/pages/<slug>.json の配列（meta/blocksなど）
     * @param array  $site  /data/site.json の配列
     * @param array  $menu  /data/menu.json の配列
     */
    function tpcms_render_page(string $slug, array $page, array $site, array $menu): string {
        ['theme'=>$theme, 'color'=>$color] = tpcms_active_theme();
        $layout = ($slug === 'index') ? 'index.html' : 'page.html';
        $layoutPath = tpcms_theme_path($theme, $color, $layout);
        $tpl = is_file($layoutPath) ? (string)file_get_contents($layoutPath) : '<!doctype html><meta charset="utf-8"><title>{{META_TITLE}}</title><body>{{HEADER}}{{CONTENT}}{{FOOTER}}</body>';

        // HEADER / FOOTER
        $headerPath = tpcms_partial_path($theme, $color, $slug === 'index' ? 'header.index.html' : 'header.html');
        if (!is_file($headerPath)) $headerPath = tpcms_partial_path($theme, $color, 'header.html');
        $footerPath = tpcms_partial_path($theme, $color, 'footer.html');
        $headerTpl = is_file($headerPath) ? (string)file_get_contents($headerPath) : '';
        $footerTpl = is_file($footerPath) ? (string)file_get_contents($footerPath) : '';
        // CREDIT（テンプレの著作表示）
        $creditPath = tpcms_partial_path($theme, $color, 'credit.html');
        $creditTpl  = is_file($creditPath) ? (string)file_get_contents($creditPath) : '';
        $credit     = tpcms_apply_template(
            $creditTpl,
            array_merge($site, is_array($site['THEME_CREDIT'] ?? null) ? $site['THEME_CREDIT'] : [])
        );
        $header = tpcms_apply_template(
            $headerTpl,
            array_merge($site, is_array($site['THEME_HEADER'] ?? null) ? $site['THEME_HEADER'] : [])
        );
        $footer = tpcms_apply_template(
            $footerTpl,
            array_merge(
                $site,
                is_array($site['THEME_FOOTER'] ?? null) ? $site['THEME_FOOTER'] : [],
                // 既に SITE 側に CREDIT_HTML がある（＝ライセンス等で制御）なら上書きしない
                array_key_exists('CREDIT_HTML', $site) ? [] : ['CREDIT_HTML' => $credit]
            )
        );
        // FREE_AREA / MAINIMG（存在すれば適用）
        $freeAreaPath = tpcms_partial_path($theme, $color, 'free_area.html');
        $freeAreaTpl  = is_file($freeAreaPath) ? (string)file_get_contents($freeAreaPath) : '';
        $freeArea     = tpcms_apply_template(
            $freeAreaTpl,
            array_merge($site, is_array($site['THEME_FREE_AREA'] ?? null) ? $site['THEME_FREE_AREA'] : [])
        );

        $mainimgPath = tpcms_partial_path($theme, $color, 'mainimg.html');
        $mainimgTpl  = is_file($mainimgPath) ? (string)file_get_contents($mainimgPath) : '';
        $mainimg     = tpcms_apply_template(
            $mainimgTpl,
            array_merge($site, is_array($site['THEME_MAINIMG'] ?? null) ? $site['THEME_MAINIMG'] : [])
        );

        $meta = is_array($page['meta'] ?? null) ? $page['meta'] : [];
        $title = (string)($meta['title'] ?? ($site['SITE_NAME'] ?? ''));
        $description = (string)($meta['description'] ?? '');

        // CONTENT（blocks → html）
        $blocks = is_array($page['blocks'] ?? null) ? $page['blocks'] : [];
        $contentHtml = tpcms_render_blocks($theme, $color, $blocks);

        // NAV
        $navHtml = tpcms_build_nav($menu, $slug);
        $navSubHtml = tpcms_build_nav_sub($menu, $slug);

        // site.json の主要キーを素直に置換できるよう連結
        $vars = array_merge($site, [
            'META_TITLE'       => $title,
            'META_DESCRIPTION' => $description,
            'HEADER'           => $header,
            'FOOTER'           => $footer,
            'FREE_AREA'        => $freeArea,
            'MAINIMG'          => $mainimg,
            'MAINIMG_HTML'     => $mainimg,
            'NAV'              => $navHtml,
            'NAV_SUB'          => $navSubHtml,
            'CONTENT'          => $contentHtml,
        ]);

        return tpcms_apply_template($tpl, $vars);
    }
}
