🧩 Prism Smart Lite 外掛說明文件

Prism Smart Lite 是一款為 WordPress 打造的輕量程式碼高亮外掛,支援前台亮/暗主題切換、行號、工具列(檔名、複製、收合、換行)、短代碼 ‘[‘codeplaceholder’]’ 與 ‘[‘codefile’]’,並具備檔案上傳、資料夾管理、描述維護與 .htaccess 安全保護功能。後台可設定顯示與進階選項、樹狀檔案管理與路徑測試,無需新增資料表。適用 WordPress 5.8+、PHP 7.4+。

🧩 Prism Smart Lite 外掛說明文件

一、外掛概述

Prism Smart Lite 是一款為 WordPress 所設計的精簡程式碼高亮外掛,支援:前台程式碼高亮(亮/暗主題)、行號、工具列(檔名/複製/收合/換行切換)、短代碼 '['codeplaceholder']''['codefile']'、檔案上傳與資料夾管理、檔案描述記錄與安全保護(.htaccess)。


二、主要功能彙整

🔹 前台顯示功能

功能 說明
短代碼 ‘[‘codeplaceholder’]’ 在文章中直接顯示貼上的原始程式碼(內容原樣保留,避免 WP 改寫)。
短代碼 ‘[‘codefile’]’ 從上傳的檔案載入並顯示程式碼(預設資料夾 /uploads/code-snippets/;可設白名單路徑)。
亮 / 暗主題切換 設定中切換,前台自動套用 Prism 主題(Okaidia / Default)。
行號顯示 可全域開啟或在單一短代碼覆寫。
工具列 檔名徽章、複製、收合、換行切換等按鈕可獨立啟用。
收合 / 展開 可設定預設為收合或展開,未展開的最大高度可自訂。
寬 / 高控制 可自訂最大寬度與未收合最大高度,避免破版。
換行控制 可切換單行橫向捲動與自動折行。

🔹 後台管理功能

功能 說明
顯示設定 主題、預設收合、最大寬度/高度。
進階設定 資源載入模式、行號、工具列、允許副檔名、白名單路徑。
檔案管理 樹狀結構瀏覽資料夾與檔案、依資料夾篩選。
上傳檔案 可上傳至目前選取的資料夾,並同時填寫描述。
描述管理 每檔可單獨編輯描述(儲於 .psl_meta.json)。
建立資料夾 於目前目錄新增子資料夾。
刪除資料夾 可刪空資料夾或遞迴刪除整個資料夾(同步清理描述記錄)。
刪除檔案 可單一刪除檔案,描述同步移除。
.htaccess 管理 一鍵生成/刪除,預設封鎖 PHP 執行與索引。
路徑測試 顯示伺服器實際路徑,利於除錯。

三、安裝方式

  1. 將外掛資料夾 prism-smart-lite 放入 /wp-content/plugins/
  2. 登入 WordPress → 外掛 → 啟用 Prism Smart Lite。
  3. 啟用後系統會自動建立 /wp-content/uploads/code-snippets/ 目錄。

四、使用流程

✅ 後台設定

  • 進入「設定 → Prism Smart Lite」。
  • 在「顯示設定」選主題、預設收合、寬高。
  • 在「進階設定」調整載入模式、行號、工具列、允許副檔名與白名單路徑。

✅ 管理檔案

  • 左側樹狀清單選資料夾 → 右側可上傳、編輯描述、刪除檔案。
  • 可建立 / 刪除資料夾(遞迴刪除將清空所有內容與描述)。
  • 建議生成 .htaccess 提升安全。

✅ 前台使用短代碼

直接貼上程式碼:

‘[‘codeplaceholder lang=”php” file=”test.php” collapsed=”0″‘]’
<?php echo “Hello World!”; ?>
‘[‘/codeplaceholder’]’

顯示上傳檔案:

‘[‘codefile src=”utils/demo.js” collapsed=”1″ linenumbers=”1″]'[‘/codefile’]’


五、資料儲存結構

  • 上傳檔案: /wp-content/uploads/code-snippets/
  • 檔案描述: /wp-content/uploads/code-snippets/.psl_meta.json
  • 外掛設定: 資料庫 wp_options 表(option_name=psl_options
  • 安全設定: /wp-content/uploads/code-snippets/.htaccess

六、注意事項

  • 在未建立 .htaccess 前,請勿上傳可執行的 PHP 檔至該資料夾。
  • 遞迴刪除資料夾會永久移除所有內容,請先備份。
  • 預設允許副檔名:php,js,css,html,txt,md,json,xml,yml,yaml,ts,tsx(可於進階設定調整)。
  • 若需顯示非上傳目錄的檔案,請在「白名單路徑」加入該資料夾絕對路徑。

七、補充說明

.psl_meta.json 由外掛自動維護;上傳/刪除/修改描述皆會同步更新。

.htaccess 預設內容:

Options -Indexes
<FilesMatch “\.(php|phtml|phar)$”>
Require all denied
</FilesMatch>

備份建議: 備份整個 /uploads/code-snippets/(含 .psl_meta.json.htaccess),再備份資料庫的 psl_options


八、適用環境

  • WordPress 5.8+
  • PHP 7.4+
  • 不需額外資料表,支援快取外掛。

九、範例應用

顯示 PHP 函式:

‘[‘codeplaceholder lang=”php” file=”functions.php”]
function add($a, $b){ return $a + $b; }
‘[‘/codeplaceholder’]’

顯示上傳的 JS 檔:

‘[‘codefile src=”scripts/app.js” linenumbers=”1″ collapsed=”1″]'[‘/codefile’]’

顯示 YAML / JSON:

‘[‘codeplaceholder lang=”yaml” file=”ci.yml”]
name: build
on: [push]
jobs: { build: { runs-on: ubuntu-latest } }
‘[‘/codeplaceholder”]’


十、版本資訊

版本 日期 更新內容
v1.1.0 2025-10 進階設定、資料夾刪除(含遞迴)、樹狀瀏覽、描述維護、.htaccess 管理;短代碼內容完整保留;單一頁設定整合;提示訊息防重複。

十一、檔案原始碼

prism-smart-lite.php

<?php
/**
 * Plugin Name: Prism Smart Lite
 * Description: 精簡版程式碼高亮外掛:行號、右上工具列(檔名/複製/收合/換行切換)、亮/暗主題。提供 [codeplaceholder] 與 [codefile];含寬度與高度上限設定,避免破版。v1.1.0:短代碼內容原樣保留(不被 WordPress 修改),並支援白名單檔案讀取。
 * Version: 1.1.1
 * Author: Lin
 * License: GPL-2.0+
 */
if (!defined('ABSPATH')) exit;

class Prism_Smart_Lite {
    const OPT_KEY = 'psl_options';
    private static $instance = null;

    public static function init(){
        if(!self::$instance) self::$instance = new self();
        return self::$instance;
    }

    private function __construct(){
        register_activation_hook(__FILE__, [$this,'on_activate']);
        add_action('wp_enqueue_scripts', [$this,'enqueue_assets']);
        add_shortcode('codeplaceholder', [$this,'shortcode_codeplaceholder']);
        add_shortcode('codebox',        [$this,'shortcode_codeplaceholder']); // 別名
        add_shortcode('codefile',       [$this,'shortcode_codefile']);        // 外部檔案讀取
    }

    /** 預設值:維持相容 + 新增可選設定 */
    private function defaults(){
        return [
            'theme'         => 'light', // light|dark(維持你原本兩種)
            'collapsed'     => '0',     // 0|1 預設是否收合
            'max_width'     => '100%',
            'max_height'    => '70vh',
            // 新增
            'enqueue_mode'  => 'all',   // all|shortcode
            'line_numbers'  => 1,       // 1|0
            'show_filename' => 1,       // 1|0
            'enable_copy'   => 1,       // 1|0
            'enable_collapse'    => 1,  // 1|0
            'enable_wrap_toggle' => 0,  // 1|0
            'allow_mimes'   => 'php,js,css,html,txt,md,json,xml,yml,yaml,ts,tsx',
            'allowed_paths' => [],      // 路徑白名單(陣列)
        ];
    }

    private function opt($key=null, $fallback=null){
        $opt = wp_parse_args(get_option(self::OPT_KEY, []), $this->defaults());
        return $key===null ? $opt : (isset($opt[$key]) ? $opt[$key] : $fallback);
    }

    public function on_activate(){
        $defaults = $this->defaults();
        $opt = get_option(self::OPT_KEY);
        update_option(self::OPT_KEY, is_array($opt) ? array_merge($defaults,$opt) : $defaults);

        // 建立 uploads/code-snippets(相容舊行為)
        $uploads  = wp_upload_dir();
        $snip_dir = trailingslashit($uploads['basedir']) . 'code-snippets';
        if (!is_dir($snip_dir)) {
            wp_mkdir_p($snip_dir);
        }
    }

    /** CDN 資源表 */
    private function get_assets(){
        $theme = $this->opt('theme', 'light');
        return [
            'css'      => $theme==='dark'
                ? 'https://cdn.jsdelivr.net/npm/prismjs@1/themes/prism-okaidia.min.css'
                : 'https://cdn.jsdelivr.net/npm/prismjs@1/themes/prism.min.css',
            'line_css' => 'https://cdn.jsdelivr.net/npm/prismjs@1/plugins/line-numbers/prism-line-numbers.min.css',
            'tb_css'   => 'https://cdn.jsdelivr.net/npm/prismjs@1/plugins/toolbar/prism-toolbar.min.css',
            'core'     => 'https://cdn.jsdelivr.net/npm/prismjs@1/components/prism-core.min.js',
            'auto'     => 'https://cdn.jsdelivr.net/npm/prismjs@1/plugins/autoloader/prism-autoloader.min.js',
            'line'     => 'https://cdn.jsdelivr.net/npm/prismjs@1/plugins/line-numbers/prism-line-numbers.min.js',
            'tb'       => 'https://cdn.jsdelivr.net/npm/prismjs@1/plugins/toolbar/prism-toolbar.min.js',
            'copy'     => 'https://cdn.jsdelivr.net/npm/prismjs@1/plugins/copy-to-clipboard/prism-copy-to-clipboard.min.js',
        ];
    }

    /** 本頁是否含短代碼(供 enqueue_mode=shortcode 用) */
    private function has_shortcode_on_page() {
        if (is_admin()) return false;
        $types = ['codeplaceholder','codebox','codefile'];
        // 單篇
        global $post;
        if ($post && !empty($post->post_content)) {
            foreach ($types as $t) if (has_shortcode($post->post_content, $t)) return true;
        }
        // 小工具/區塊等(簡易偵測)
        $buf = get_the_content(null, false, $post);
        if ($buf) {
            foreach ($types as $t) if (strpos($buf, "[$t") !== false) return true;
        }
        return false;
    }

    /** 前端載入 */
    public function enqueue_assets(){
        $mode = $this->opt('enqueue_mode', 'all'); // all|shortcode
        if ($mode === 'shortcode' && !$this->has_shortcode_on_page()) return;

        $u = $this->get_assets();
        wp_enqueue_style('psl-theme',   $u['css'], [], '1.0');
        wp_enqueue_style('psl-linecss', $u['line_css'], [], '1.0');
        wp_enqueue_style('psl-tbcss',   $u['tb_css'], [], '1.0');

        wp_enqueue_script('psl-core', $u['core'], [], '1.0', true);
        wp_enqueue_script('psl-auto', $u['auto'], ['psl-core'], '1.0', true);
        wp_enqueue_script('psl-line', $u['line'], ['psl-core'], '1.0', true);
        wp_enqueue_script('psl-tb',   $u['tb'],   ['psl-core'], '1.0', true);

        if ($this->opt('enable_copy',1)) {
            wp_enqueue_script('psl-copy', $u['copy'], ['psl-tb'], '1.0', true);
        }

        wp_add_inline_script('psl-auto','Prism.plugins.autoloader.languages_path="https://cdn.jsdelivr.net/npm/prismjs@1/components/";','before');

        // CSS:寬/高(後台可調)
        $max_w = trim($this->opt('max_width','100%'));
        $max_h = trim($this->opt('max_height','70vh'));
        $css = <<<CSS
.psl-wrap{
  position:relative;
  max-width: {$max_w};
  width:100%;
  overflow:hidden;
  margin-left:auto;margin-right:auto;
}
pre.psl-pre{
  margin:1.25rem 0;
  border-radius:10px;
  max-width:100%;
  box-sizing:border-box;
  overflow:auto;
  white-space:pre; /* 不自動換行 */
}
pre.psl-pre.line-numbers{position:relative;padding-left:3.8em;}
pre.psl-pre code{display:block;white-space:pre;word-break:normal;overflow-wrap:normal;}
pre.psl-pre:not(.collapsed){max-height: {$max_h};}
pre.psl-pre.collapsed{max-height:140px;overflow:hidden;position:relative;}
pre.psl-pre.collapsed::after{
  content:'';position:absolute;left:0;right:0;bottom:0;height:48px;
  background:linear-gradient(transparent,rgba(0,0,0,.08));pointer-events:none;
}
/* Toolbar 微調 */
.psl-filename{padding:0 10px;opacity:.8;font-size:12px;}
div.code-toolbar>.toolbar{gap:6px;}
.line-numbers .line-numbers-rows{box-sizing:content-box;}
/* 自動換行模式(由按鈕切換時掛上 .wrap-lines) */
pre.psl-pre.wrap-lines code{white-space:pre-wrap;word-break:break-word;}
CSS;
        wp_add_inline_style('psl-theme', $css);

        // 工具列按鈕(依設定動態註冊)
        $enable_filename = $this->opt('show_filename',1) ? 'true' : 'false';
        $enable_collapse = $this->opt('enable_collapse',1) ? 'true' : 'false';
        $enable_wrap     = $this->opt('enable_wrap_toggle',0) ? 'true' : 'false';
        $collapsed_def   = !empty($this->opt('collapsed','0')) ? '1' : '0';
        $line_numbers_on = $this->opt('line_numbers',1) ? 'true' : 'false';

        $js = <<<JS
(function(){
  function ensureLineNumbers(){
    if ($line_numbers_on) {
      document.querySelectorAll('pre.psl-pre').forEach(function(pre){
        pre.classList.add('line-numbers');
      });
    } else {
      document.querySelectorAll('pre.psl-pre').forEach(function(pre){
        pre.classList.remove('line-numbers');
      });
    }
  }

  if (Prism && Prism.plugins && Prism.plugins.toolbar){
    if ($enable_filename) {
      Prism.plugins.toolbar.registerButton('psl-filename', function(env){
        var f = env.element.getAttribute('data-file'); if(!f) return;
        var s = document.createElement('span'); s.className='psl-filename'; s.textContent=f; return s;
      });
    }
    if ($enable_collapse) {
      Prism.plugins.toolbar.registerButton('psl-collapse', function(env){
        var b=document.createElement('button'); b.textContent='收合';
        b.addEventListener('click', function(){
          var pre = env.element.closest('pre'); if(!pre) return;
          pre.classList.toggle('collapsed');
          b.textContent = pre.classList.contains('collapsed') ? '展開' : '收合';
        });
        return b;
      });
    }
    if ($enable_wrap) {
      Prism.plugins.toolbar.registerButton('psl-wrap', function(env){
        var btn=document.createElement('button'); btn.textContent='換行';
        btn.addEventListener('click', function(){
          var pre = env.element.closest('pre'); if(!pre) return;
          pre.classList.toggle('wrap-lines'); // 切換 CSS white-space
        });
        return btn;
      });
    }
  }

  document.addEventListener('DOMContentLoaded', function(){
    ensureLineNumbers();
    var def = ('{$collapsed_def}' === '1');
    document.querySelectorAll('pre.psl-pre').forEach(function(pre){
      if(pre.getAttribute('data-collapsed')==='1' || def) pre.classList.add('collapsed');
    });
  });
})();
JS;
        wp_add_inline_script('psl-tb', $js, 'after');
    }

    /** 內文短代碼:保留原文輸出(你的作法) */
    public function shortcode_codeplaceholder($atts, $content=null){
        $collapsed_default = !empty($this->opt('collapsed','0')) ? '1' : '0';
        $a = shortcode_atts([
            'file'      => 'snippet.txt',
            'lang'      => 'markup',
            'collapsed' => '',
            'linenumbers' => '', // 可用於單塊覆寫行號:1/0
        ], $atts, 'codeplaceholder');

        $collapsed_attr = ($a['collapsed']==='0' || $a['collapsed']==='1') ? $a['collapsed'] : $collapsed_default;

        $code = (string)$content;
        if ($code === '') $code = "/* 把程式碼貼在這裡 */";
        $code = esc_html($code); // 標準流程(方案A),方案B由 do_shortcode_tag 攔截原樣

        $classes = 'psl-pre';
        // 若全域開著行號,或單塊顯式 linenumbers=1,才加上
        $global_ln = $this->opt('line_numbers',1);
        if (($a['linenumbers'] === '1') || ($a['linenumbers'] === '' && $global_ln)) {
            $classes .= ' line-numbers';
        }

        return '<div class="psl-wrap"><pre class="'.esc_attr($classes).'" data-collapsed="'.
               esc_attr($collapsed_attr).'"><code class="language-'.esc_attr($a['lang']).
               '" data-file="'.esc_attr($a['file']).'">'.$code.'</code></pre></div>';
    }

    /** 外部檔案短代碼:支援白名單路徑 + 副檔名白名單 */
    public function shortcode_codefile($atts) {
        $atts = shortcode_atts([
            'src'       => '',
            'lang'      => 'auto',
            'collapsed' => '',
            'file'      => '', // 覆寫右上角檔名顯示
            'linenumbers' => '', // 覆寫行號
        ], $atts, 'codefile');

        if ($atts['src'] === '') {
            return '<div class="psl-wrap"><pre class="psl-pre"><code>/* codefile:請提供 src 屬性 */</code></pre></div>';
        }

        $allowed_paths = $this->opt('allowed_paths', []);
        $allowed_exts  = array_values(array_filter(array_map(function($e){
            $e = strtolower(preg_replace('/[^a-z0-9]/','', trim($e)));
            return $e;
        }, explode(',', (string)$this->opt('allow_mimes','php,js,css,html,txt,md,json,xml,yml,yaml,ts,tsx')))));

        // 若未設定白名單,沿用你原本 uploads/code-snippets/ 作為回退
        $uploads  = wp_upload_dir();
        $fallback_base = trailingslashit($uploads['basedir']) . 'code-snippets';
        if (!is_dir($fallback_base)) { wp_mkdir_p($fallback_base); }
        if (empty($allowed_paths)) {
            $real_bases = [ realpath($fallback_base) ];
        } else {
            $real_bases = array_values(array_filter(array_map(function($p){
                $real = realpath($p);
                return ($real && is_dir($real)) ? untrailingslashit($real) : null;
            }, (array)$allowed_paths)));
            // 同時把 fallback 也加進去(保留相容)
            $real_fb = realpath($fallback_base);
            if ($real_fb) $real_bases[] = $real_fb;
        }

        // 解析目標檔案實體路徑(支援相對於白名單路徑的 src)
        $candidate_paths = [];
        foreach ($real_bases as $base) {
            $candidate_paths[] = $base . '/' . ltrim($atts['src'], '/');
        }
        // 同時允許傳入絕對路徑(若在白名單之下)
        if (substr($atts['src'],0,1) === '/' && file_exists($atts['src'])) {
            $candidate_paths[] = $atts['src'];
        }

        $target_real = null;
        foreach ($candidate_paths as $tgt) {
            $real_tgt = realpath($tgt);
            if (!$real_tgt || !is_file($real_tgt) || !is_readable($real_tgt)) continue;
            // 必須在任一 base 之下
            $ok_base = false;
            foreach ($real_bases as $base) {
                if ($base && strpos($real_tgt, $base) === 0) { $ok_base = true; break; }
            }
            if (!$ok_base) continue;

            // 檔案副檔名白名單
            $ext = strtolower(pathinfo($real_tgt, PATHINFO_EXTENSION));
            if (!in_array($ext, $allowed_exts, true)) continue;

            $target_real = $real_tgt; break;
        }

        if (!$target_real) {
            return '<div class="psl-wrap"><pre class="psl-pre"><code>/* codefile:檔案不在白名單或副檔名未允許:' . esc_html($atts['src']) . ' */</code></pre></div>';
        }

        // 大小上限(>1MB 拒絕)
        if (filesize($target_real) > 1024 * 1024) {
            return '<div class="psl-wrap"><pre class="psl-pre"><code>/* codefile:檔案過大(>1MB) */</code></pre></div>';
        }

        $raw  = file_get_contents($target_real);
        $code = str_replace(['<','>'], ['<','>'], $raw);

        // 語言
        $lang = $atts['lang'];
        if ($lang === 'auto' || $lang === '' ) {
            $ext = strtolower(pathinfo($target_real, PATHINFO_EXTENSION));
            $map = [
                'php'=>'php','phps'=>'php','js'=>'javascript','mjs'=>'javascript','cjs'=>'javascript',
                'ts'=>'typescript','tsx'=>'tsx','css'=>'css','scss'=>'scss','sass'=>'sass','less'=>'less',
                'html'=>'markup','htm'=>'markup','xml'=>'markup','json'=>'json','yml'=>'yaml','yaml'=>'yaml',
                'py'=>'python','rb'=>'ruby','go'=>'go','java'=>'java','c'=>'c','cpp'=>'cpp','cs'=>'csharp',
                'sql'=>'sql','sh'=>'bash','bat'=>'batch','md'=>'markdown'
            ];
            $lang = $map[$ext] ?? 'markup';
        }

        $display_name = $atts['file'] !== '' ? $atts['file'] : basename($target_real);
        $collapsed_default = !empty($this->opt('collapsed','0')) ? '1' : '0';
        $collapsed_attr = ($atts['collapsed']==='0' || $atts['collapsed']==='1') ? $atts['collapsed'] : $collapsed_default;

        $classes = 'psl-pre';
        $global_ln = $this->opt('line_numbers',1);
        if (($atts['linenumbers'] === '1') || ($atts['linenumbers'] === '' && $global_ln)) {
            $classes .= ' line-numbers';
        }

        return '<div class="psl-wrap"><pre class="'.esc_attr($classes).'" data-collapsed="' . esc_attr($collapsed_attr) .
               '"><code class="language-' . esc_attr($lang) . '" data-file="' . esc_attr($display_name) . '">' . $code .
               '</code></pre></div>';
    }
}
Prism_Smart_Lite::init();

/* 正規化儲存值(保留你的鉤子,並兼容新欄位) */
add_action('pre_update_option_' . Prism_Smart_Lite::OPT_KEY, function($new){
    $d = [
        'theme'=>'light','collapsed'=>'0','max_width'=>'100%','max_height'=>'70vh',
        'enqueue_mode'=>'all','line_numbers'=>1,'show_filename'=>1,'enable_copy'=>1,
        'enable_collapse'=>1,'enable_wrap_toggle'=>0,'allow_mimes'=>'php,js,css,html,txt,md,json,xml,yml,yaml,ts,tsx',
        'allowed_paths'=>[]
    ];
    $new = is_array($new) ? array_merge($d, $new) : $d;

    $new['theme']      = ($new['theme']==='dark') ? 'dark' : 'light';
    $new['collapsed']  = (!empty($new['collapsed'])) ? '1' : '0';
    $new['max_width']  = trim($new['max_width']  ?? '100%');
    $new['max_height'] = trim($new['max_height'] ?? '70vh');

    $new['enqueue_mode'] = in_array($new['enqueue_mode'], ['all','shortcode'], true) ? $new['enqueue_mode'] : 'all';
    foreach (['line_numbers','show_filename','enable_copy','enable_collapse','enable_wrap_toggle'] as $k) {
        $new[$k] = empty($new[$k]) ? 0 : 1;
    }

    // 清理 allow_mimes
    $exts = array_filter(array_map('trim', explode(',', (string)$new['allow_mimes'])));
    $exts = array_map(function($e){ return strtolower(preg_replace('/[^a-z0-9]/','',$e)); }, $exts);
    $exts = array_values(array_unique(array_filter($exts)));
    $new['allow_mimes'] = implode(',', $exts);

    // 路徑白名單(陣列或多行字串)
    if (!is_array($new['allowed_paths'])) {
        $paths_raw = explode("\n", (string)$new['allowed_paths']);
        $clean = [];
        foreach ($paths_raw as $p) {
            $p = trim($p);
            if ($p === '') continue;
            $real = realpath($p);
            if ($real && is_dir($real)) $clean[] = untrailingslashit($real);
        }
        $new['allowed_paths'] = array_values(array_unique($clean));
    } else {
        $new['allowed_paths'] = array_values(array_unique(array_map(function($p){
            $p = trim((string)$p);
            $real = realpath($p);
            return ($real && is_dir($real)) ? untrailingslashit($real) : null;
        }, $new['allowed_paths'])));
        $new['allowed_paths'] = array_values(array_filter($new['allowed_paths']));
    }

    return $new;
}, 10, 1);

/**
 * 🚫 完整保留短代碼內原文(不讓 WP 改動任何字元)
 * 利用 do_shortcode_tag 取得原始匹配:$m[5] 是短代碼內文原樣
 * 只轉義 < >,避免被當成 HTML,其他全部保留
 */
add_filter('do_shortcode_tag', function ($output, $tag, $attr, $m) {
    if ($tag !== 'codeplaceholder' && $tag !== 'codebox') return $output;

    // 原樣內文
    $raw = isset($m[5]) ? $m[5] : '';

    // 解析屬性
    $atts = shortcode_parse_atts($m[3] ?? '') ?: [];
    $opt = wp_parse_args(get_option(Prism_Smart_Lite::OPT_KEY, []), [
        'collapsed'=>'0','line_numbers'=>1,'show_filename'=>1
    ]);

    $file = isset($atts['file']) ? $atts['file'] : 'snippet.txt';
    $lang = isset($atts['lang']) ? $atts['lang'] : 'markup';

    $collapsed_default = !empty($opt['collapsed']) ? '1' : '0';
    $collapsed_attr = (isset($atts['collapsed']) && ($atts['collapsed']==='0' || $atts['collapsed']==='1'))
        ? $atts['collapsed']
        : $collapsed_default;

    $code = str_replace(['<','>'], ['<','>'], $raw);
    if ($code === '') $code = "/* 把程式碼貼在這裡 */";

    $classes = 'psl-pre';
    $ln_single = isset($atts['linenumbers']) ? $atts['linenumbers'] : '';
    $global_ln = !empty($opt['line_numbers']);
    if (($ln_single === '1') || ($ln_single === '' && $global_ln)) {
        $classes .= ' line-numbers';
    }

    $html = '<div class="psl-wrap"><pre class="'.esc_attr($classes).'" data-collapsed="'.
            esc_attr($collapsed_attr).'"><code class="language-'.esc_attr($lang).'"'.
            ' data-file="'.esc_attr($file).'">'.$code.'</code></pre></div>';

    return $html;
}, 10, 4);

/**
 * ⛔ 文章若含 codeplaceholder/codebox,暫時停用 WP 自動處理(避免任何改寫)
 */
add_filter('the_content', function ($content) {
    if (strpos($content, '[codeplaceholder') !== false || strpos($content, '[codebox') !== false) {
        remove_filter('the_content', 'wpautop');
        remove_filter('the_content', 'wptexturize');
        remove_filter('the_content', 'convert_chars');
        remove_filter('the_content', 'convert_smilies');
    }
    return $content;
}, 0);

/** 設定頁(分檔) */
require_once plugin_dir_path(__FILE__) . 'prism-smart-lite-settings.php';

prism-smart-lite.php

<?php
/**
 * Prism Smart Lite - Settings & File Manager (Enhanced with Folder Delete + Advanced Settings)
 * 放到與 prism-smart-lite.php 同一資料夾
 */
if (!defined('ABSPATH')) exit;

/** ========= 基本常數 / 幫手 ========= */
define('PSL_META_FILE', '.psl_meta.json');

function psl_get_snippets_dir() {
    $uploads = wp_upload_dir();
    return trailingslashit($uploads['basedir']) . 'code-snippets';
}
function psl_ensure_snippets_dir() {
    $dir = psl_get_snippets_dir();
    if (!is_dir($dir)) wp_mkdir_p($dir);
    return $dir;
}
function psl_htaccess_content() {
    return "Options -Indexes\n<FilesMatch \"\\.(php|phtml|phar)$\">\n  Require all denied\n</FilesMatch>\n";
}
function psl_is_our_settings_screen() {
    return isset($_GET['page']) && $_GET['page'] === 'psl_settings';
}

/** ========= 目錄/檔案掃描 ========= */
function psl_scan_files($dir) {
    $out = [];
    $real_base = realpath($dir);
    if (!$real_base) return $out;
    $it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($real_base, FilesystemIterator::SKIP_DOTS));
    foreach ($it as $file) {
        if ($file->isFile()) {
            $real = $file->getRealPath();
            $rel = ltrim(str_replace($real_base, '', $real), '/\\');
            $out[] = [
                'real'  => $real,
                'rel'   => $rel,
                'size'  => $file->getSize(),
                'mtime' => $file->getMTime(),
            ];
        }
    }
    return $out;
}
function psl_scan_tree($base_dir) {
    $base = realpath($base_dir);
    $tree = [];
    if (!$base) return $tree;

    $it = new RecursiveDirectoryIterator($base, FilesystemIterator::SKIP_DOTS);
    $rii = new RecursiveIteratorIterator($it, RecursiveIteratorIterator::SELF_FIRST);
    foreach ($rii as $item) {
        $rel = ltrim(str_replace($base, '', $item->getRealPath()), '/\\');
        $parts = $rel === '' ? [] : explode(DIRECTORY_SEPARATOR, $rel);
        $node =& $tree;
        foreach ($parts as $i => $p) {
            if ($i === count($parts)-1) {
                if ($item->isDir()) {
                    if (!isset($node[$p])) $node[$p] = ['__type'=>'dir','__children'=>[]];
                } else {
                    $node[$p] = ['__type'=>'file'];
                }
            } else {
                if (!isset($node[$p])) $node[$p] = ['__type'=>'dir','__children'=>[]];
                $node =& $node[$p]['__children'];
            }
        }
    }
    return ['__type'=>'dir','__children'=>$tree]; // root
}

/** ========= 描述檔(JSON) ========= */
function psl_meta_load($base_dir) {
    $file = trailingslashit($base_dir) . PSL_META_FILE;
    if (!file_exists($file)) return [];
    $raw = file_get_contents($file);
    $json = json_decode($raw, true);
    return is_array($json) ? $json : [];
}
function psl_meta_save($base_dir, $data) {
    $file = trailingslashit($base_dir) . PSL_META_FILE;
    @file_put_contents($file, wp_json_encode($data, JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE|JSON_PRETTY_PRINT));
    @chmod($file, 0644);
}
function psl_meta_get($base_dir, $rel) {
    $m = psl_meta_load($base_dir);
    return isset($m[$rel]) ? (string)$m[$rel] : '';
}
function psl_meta_set($base_dir, $rel, $desc) {
    $m = psl_meta_load($base_dir);
    if (trim($desc) === '') unset($m[$rel]);
    else $m[$rel] = $desc;
    psl_meta_save($base_dir, $m);
}
function psl_meta_delete($base_dir, $rel) {
    $m = psl_meta_load($base_dir);
    if (isset($m[$rel])) { unset($m[$rel]); psl_meta_save($base_dir, $m); }
}

/** === A) Folder Delete Helpers: 安全刪除目錄 + 清理描述(新增) === */
function psl_meta_prune_prefix($base_dir, $prefixRel) {
    // 刪除 .psl_meta.json 中所有以 prefix 開頭的描述
    $m = psl_meta_load($base_dir);
    $changed = false;
    foreach ($m as $rel => $desc) {
        if ($rel === $prefixRel || strpos($rel, rtrim($prefixRel,'/').'/') === 0) {
            unset($m[$rel]); $changed = true;
        }
    }
    if ($changed) psl_meta_save($base_dir, $m);
}
function psl_rrmdir($dir) {
    if (!is_dir($dir)) return true;
    $items = new FilesystemIterator($dir, FilesystemIterator::SKIP_DOTS);
    foreach ($items as $item) {
        if ($item->isDir()) {
            psl_rrmdir($item->getPathname());
        } else {
            @unlink($item->getPathname());
        }
    }
    return @rmdir($dir);
}

/** ========= Admin Menu / Notices ========= */
add_action('admin_menu', function(){
    add_options_page('Prism Smart Lite', 'Prism Smart Lite', 'manage_options', 'psl_settings', 'psl_render_settings_page');
});

/* (1)修正重複顯示:集中在 admin_notices 單一輸出一次 */
add_action('admin_notices', function(){
    if (!current_user_can('manage_options')) return;
    if (!psl_is_our_settings_screen()) return;
    static $printed = false;
    if ($printed) return;
    $printed = true;
    settings_errors('psl_msgs');
});

/** ========= 動作處理 ========= */
add_action('admin_init', function(){
    if (!current_user_can('manage_options')) return;

    // 儲存顯示設定(維持原本)
    if (isset($_POST['psl_action']) && $_POST['psl_action']==='psl_save_options' && check_admin_referer('psl_opts_nonce')) {
        $o = get_option('psl_options', []);
        $o['theme']      = (isset($_POST['psl_theme']) && $_POST['psl_theme']==='dark') ? 'dark' : 'light';
        $o['collapsed']  = !empty($_POST['psl_collapsed']) ? '1' : '0';
        $o['max_width']  = sanitize_text_field($_POST['psl_max_width'] ?? '100%');
        $o['max_height'] = sanitize_text_field($_POST['psl_max_height'] ?? '70vh');
        update_option('psl_options', $o);
        add_settings_error('psl_msgs', 'psl_ok_'.uniqid(), '設定已儲存', 'updated');
    }

    // ✅ 新增:儲存進階設定(鍵名對齊主程式)
    if (isset($_POST['psl_action']) && $_POST['psl_action']==='psl_save_advanced' && check_admin_referer('psl_adv_nonce')) {
        $o = get_option('psl_options', []);
        // select
        $enqueue_mode = in_array(($_POST['psl_enqueue_mode'] ?? 'all'), ['all','shortcode'], true) ? $_POST['psl_enqueue_mode'] : 'all';
        $o['enqueue_mode'] = $enqueue_mode;

        // checkboxes → 1/0
        foreach (['line_numbers','show_filename','enable_copy','enable_collapse','enable_wrap_toggle'] as $k) {
            $o[$k] = !empty($_POST['psl_'.$k]) ? 1 : 0;
        }

        // allow_mimes:清理
        $exts = array_filter(array_map('trim', explode(',', (string)($_POST['psl_allow_mimes'] ?? ''))));
        $exts = array_map(function($e){ return strtolower(preg_replace('/[^a-z0-9]/','',$e)); }, $exts);
        $exts = array_values(array_unique(array_filter($exts)));
        $o['allow_mimes'] = implode(',', $exts);

        // allowed_paths:每行一個目錄,僅收存在的資料夾
        $paths_raw = explode("\n", (string)($_POST['psl_allowed_paths'] ?? ''));
        $clean = [];
        foreach ($paths_raw as $p) {
            $p = trim($p);
            if ($p === '') continue;
            $real = realpath($p);
            if ($real && is_dir($real)) $clean[] = untrailingslashit($real);
        }
        $o['allowed_paths'] = array_values(array_unique($clean));

        update_option('psl_options', $o);
        add_settings_error('psl_msgs', 'psl_ok_'.uniqid(), '進階設定已儲存', 'updated');
    }

    // 生成/刪除 .htaccess
    if (isset($_POST['psl_action']) && in_array($_POST['psl_action'], ['psl_gen_htaccess','psl_del_htaccess'], true) && check_admin_referer('psl_htaccess_nonce')) {
        $dir  = psl_ensure_snippets_dir();
        $file = $dir . '/.htaccess';
        if ($_POST['psl_action']==='psl_gen_htaccess') {
            file_put_contents($file, psl_htaccess_content());
            @chmod($file, 0644);
            add_settings_error('psl_msgs', 'psl_ok_'.uniqid(), '.htaccess 已生成', 'updated');
        } else {
            if (file_exists($file)) { unlink($file); add_settings_error('psl_msgs', 'psl_ok_'.uniqid(), '.htaccess 已刪除', 'updated'); }
            else { add_settings_error('psl_msgs', 'psl_err_'.uniqid(), '.htaccess 不存在', 'error'); }
        }
    }

    // 測試路徑
    if (isset($_POST['psl_action']) && $_POST['psl_action']==='psl_testpath' && check_admin_referer('psl_htaccess_nonce')) {
        $dir = psl_ensure_snippets_dir();
        $real = realpath($dir);
        add_settings_error('psl_msgs', 'psl_ok_'.uniqid(), '基底路徑:' . esc_html($real), 'updated');
    }

    // 建立資料夾
    if (isset($_POST['psl_action']) && $_POST['psl_action']==='psl_mkdir' && check_admin_referer('psl_mkdir_nonce')) {
        $dir  = psl_ensure_snippets_dir();
        $base = $dir;
        $current = sanitize_text_field($_POST['psl_current'] ?? '');
        if ($current !== '') $base = trailingslashit($dir) . $current;

        $name = sanitize_text_field($_POST['psl_folder'] ?? '');
        $name = trim($name, "/ \t\n\r\0\x0B");
        if ($name !== '') {
            $target = trailingslashit($base) . $name;
            if (!is_dir($target)) {
                wp_mkdir_p($target);
                add_settings_error('psl_msgs', 'psl_ok_'.uniqid(), '已建立資料夾:' . esc_html(trim(($current ? $current.'/' : '').$name,'/')), 'updated');
            } else {
                add_settings_error('psl_msgs', 'psl_err_'.uniqid(), '資料夾已存在', 'error');
            }
        }
    }

    // 上傳檔案(可寫入描述),支援選取的目前資料夾
    if (isset($_POST['psl_action']) && $_POST['psl_action']==='psl_upload' && check_admin_referer('psl_upload_nonce')) {
        $dir = psl_ensure_snippets_dir();
        $current = sanitize_text_field($_POST['psl_current'] ?? '');
        $subdir  = trim($current !== '' ? $current : (sanitize_text_field($_POST['psl_subdir'] ?? '')), "/ \t\n\r\0\x0B");
        $target_base = $dir;
        if ($subdir !== '') {
            $target_base = $dir . '/' . $subdir;
            if (!is_dir($target_base)) wp_mkdir_p($target_base);
        }

        if (!empty($_FILES['psl_file']) && $_FILES['psl_file']['error'] === UPLOAD_ERR_OK) {
            $name = sanitize_file_name($_FILES['psl_file']['name']);
            $target = $target_base . '/' . $name;
            $overwrite = !empty($_POST['psl_overwrite']);
            if (file_exists($target) && !$overwrite) {
                $name = time() . '-' . $name;
                $target = $target_base . '/' . $name;
            }
            if (move_uploaded_file($_FILES['psl_file']['tmp_name'], $target)) {
                @chmod($target, 0644);
                // 寫入描述
                $desc = isset($_POST['psl_desc']) ? wp_kses_post($_POST['psl_desc']) : '';
                $rel  = ltrim(str_replace(realpath($dir), '', realpath($target)), '/\\');
                if ($desc !== '') psl_meta_set($dir, $rel, $desc);
                add_settings_error('psl_msgs', 'psl_ok_'.uniqid(), '檔案已上傳:' . esc_html(($subdir? $subdir.'/' : '').basename($target)), 'updated');
            } else {
                add_settings_error('psl_msgs', 'psl_err_'.uniqid(), '檔案上傳失敗', 'error');
            }
        } else {
            add_settings_error('psl_msgs', 'psl_err_'.uniqid(), '未選擇檔案或上傳錯誤', 'error');
        }
    }

    // 刪除檔案(同步移除描述)
    if (isset($_POST['psl_action']) && $_POST['psl_action']==='psl_delete_file' && check_admin_referer('psl_delete_nonce')) {
        $dir  = psl_ensure_snippets_dir();
        $file = sanitize_text_field($_POST['psl_path'] ?? '');
        $file = trim($file, "/ ");
        $real = realpath($dir . '/' . $file);
        $real_base = realpath($dir);
        if ($real && $real_base && strpos($real, $real_base) === 0 && is_file($real)) {
            unlink($real);
            psl_meta_delete($dir, $file);
            add_settings_error('psl_msgs', 'psl_ok_'.uniqid(), '已刪除:' . esc_html($file), 'updated');
        } else {
            add_settings_error('psl_msgs', 'psl_err_'.uniqid(), '找不到檔案或無法刪除', 'error');
        }
    }

    // 更新描述(列表內即時編輯)
    if (isset($_POST['psl_action']) && $_POST['psl_action']==='psl_update_desc' && check_admin_referer('psl_desc_nonce')) {
        $dir  = psl_ensure_snippets_dir();
        $file = sanitize_text_field($_POST['psl_path'] ?? '');
        $desc = isset($_POST['psl_desc_edit']) ? wp_kses_post($_POST['psl_desc_edit']) : '';
        // 安全檢查路徑
        $real = realpath($dir . '/' . $file);
        $real_base = realpath($dir);
        if ($real && $real_base && strpos($real, $real_base) === 0 && is_file($real)) {
            psl_meta_set($dir, $file, $desc);
            add_settings_error('psl_msgs', 'psl_ok_'.uniqid(), '描述已更新:' . esc_html($file), 'updated');
        } else {
            add_settings_error('psl_msgs', 'psl_err_'.uniqid(), '找不到檔案,無法更新描述', 'error');
        }
    }

    // === D) 刪除資料夾(新增動作) ===
    if (isset($_POST['psl_action']) && $_POST['psl_action']==='psl_rmdir' && check_admin_referer('psl_rmdir_nonce')) {
        $base = psl_ensure_snippets_dir();
        $rel  = sanitize_text_field($_POST['psl_current'] ?? ''); // 目前選取資料夾(相對於 base)
        $recursive = !empty($_POST['psl_recursive']); // 是否遞迴刪除
        $rel = trim($rel, "/ ");

        if ($rel === '') {
            add_settings_error('psl_msgs', 'psl_err_'.uniqid(), '不能刪除根目錄', 'error');
        } else {
            $real_base = realpath($base);
            $target    = $real_base . '/' . $rel;
            $real_tgt  = realpath($target);

            if (!$real_base || !$real_tgt || strpos($real_tgt, $real_base) !== 0 || !is_dir($real_tgt)) {
                add_settings_error('psl_msgs', 'psl_err_'.uniqid(), '路徑不合法或資料夾不存在', 'error');
            } else {
                if ($recursive) {
                    if (psl_rrmdir($real_tgt)) {
                        psl_meta_prune_prefix($base, $rel);
                        add_settings_error('psl_msgs', 'psl_ok_'.uniqid(), '已刪除資料夾(含所有內容):' . esc_html($rel), 'updated');
                    } else {
                        add_settings_error('psl_msgs', 'psl_err_'.uniqid(), '刪除失敗', 'error');
                    }
                } else {
                    $is_empty = true;
                    $it = new FilesystemIterator($real_tgt, FilesystemIterator::SKIP_DOTS);
                    foreach ($it as $x) { $is_empty = false; break; }
                    if (!$is_empty) {
                        add_settings_error('psl_msgs', 'psl_err_'.uniqid(), '資料夾非空,請勾選「同時刪除內容」', 'error');
                    } else {
                        if (@rmdir($real_tgt)) {
                            psl_meta_prune_prefix($base, $rel);
                            add_settings_error('psl_msgs', 'psl_ok_'.uniqid(), '已刪除資料夾:' . esc_html($rel), 'updated');
                        } else {
                            add_settings_error('psl_msgs', 'psl_err_'.uniqid(), '刪除失敗', 'error');
                        }
                    }
                }
            }
        }
    }
});

/** ========= 設定頁面 ========= */
function psl_render_settings_page() {
    if (!current_user_can('manage_options')) return;

    $dir      = psl_ensure_snippets_dir();
    $real_dir = realpath($dir);
    $htpath   = $dir . '/.htaccess';
    $has_ht   = file_exists($htpath);
    $files    = psl_scan_files($dir);
    $tree     = psl_scan_tree($dir);
    $meta     = psl_meta_load($dir);

    // 讀取目前選項(含進階;主程式會用到)
    $opts_def = [
        'theme'=>'light','collapsed'=>'0','max_width'=>'100%','max_height'=>'70vh',
        'enqueue_mode'=>'all','line_numbers'=>1,'show_filename'=>1,'enable_copy'=>1,'enable_collapse'=>1,'enable_wrap_toggle'=>0,
        'allow_mimes'=>'php,js,css,html,txt,md,json,xml,yml,yaml,ts,tsx','allowed_paths'=>[]
    ];
    $opts = wp_parse_args(get_option('psl_options', []), $opts_def);
    ?>
    <div class="wrap">
        <h1>Prism Smart Lite — 設定與檔案管理</h1>

        <h2>顯示設定</h2>
        <form method="post">
            <?php wp_nonce_field('psl_opts_nonce'); ?>
            <input type="hidden" name="psl_action" value="psl_save_options">
            <table class="form-table">
                <tr><th>主題</th><td>
                    <label><input type="radio" name="psl_theme" value="light" <?php checked($opts['theme'],'light'); ?>> 亮色</label>
                    <label style="margin-left:10px;"><input type="radio" name="psl_theme" value="dark" <?php checked($opts['theme'],'dark'); ?>> 暗色</label>
                </td></tr>
                <tr><th>預設收合</th><td>
                    <label><input type="checkbox" name="psl_collapsed" value="1" <?php checked($opts['collapsed'],'1'); ?>> 預設收合</label>
                </td></tr>
                <tr><th>顯示寬度</th><td><input type="text" name="psl_max_width" value="<?php echo esc_attr($opts['max_width']); ?>">(例如 100% 或 960px)</td></tr>
                <tr><th>未收合最大高度</th><td><input type="text" name="psl_max_height" value="<?php echo esc_attr($opts['max_height']); ?>">(例如 70vh 或 560px)</td></tr>
            </table>
            <?php submit_button('儲存顯示設定'); ?>
        </form>

        <!-- ✅ 進階設定(同頁) -->
        <h2 style="margin-top:24px;">進階設定</h2>
        <form method="post">
            <?php wp_nonce_field('psl_adv_nonce'); ?>
            <input type="hidden" name="psl_action" value="psl_save_advanced">
            <table class="form-table">
                <tr>
                    <th>資源載入</th>
                    <td>
                        <label><input type="radio" name="psl_enqueue_mode" value="all" <?php checked($opts['enqueue_mode'],'all'); ?>> 所有前端頁面</label>
                        <label style="margin-left:10px;"><input type="radio" name="psl_enqueue_mode" value="shortcode" <?php checked($opts['enqueue_mode'],'shortcode'); ?>> 僅偵測到短代碼時</label>
                    </td>
                </tr>
                <tr>
                    <th>行號</th>
                    <td><label><input type="checkbox" name="psl_line_numbers" value="1" <?php checked(!empty($opts['line_numbers'])); ?>> 顯示行號(可在單一短代碼用 <code>linenumbers="0|1"</code> 覆寫)</label></td>
                </tr>
                <tr>
                    <th>工具列</th>
                    <td>
                        <label><input type="checkbox" name="psl_show_filename" value="1" <?php checked(!empty($opts['show_filename'])); ?>> 檔名徽章</label>
                        <label style="margin-left:10px;"><input type="checkbox" name="psl_enable_copy" value="1" <?php checked(!empty($opts['enable_copy'])); ?>> 複製按鈕</label>
                        <label style="margin-left:10px;"><input type="checkbox" name="psl_enable_collapse" value="1" <?php checked(!empty($opts['enable_collapse'])); ?>> 收合/展開按鈕</label>
                        <label style="margin-left:10px;"><input type="checkbox" name="psl_enable_wrap_toggle" value="1" <?php checked(!empty($opts['enable_wrap_toggle'])); ?>> 換行切換按鈕</label>
                    </td>
                </tr>
                <tr>
                    <th>允許副檔名</th>
                    <td>
                        <input type="text" class="regular-text" name="psl_allow_mimes" value="<?php echo esc_attr($opts['allow_mimes']); ?>">
                        <p class="description">以逗號分隔,例如:php,js,css,html,txt,md,json,xml,yml,yaml,ts,tsx</p>
                    </td>
                </tr>
                <tr>
                    <th>允許讀檔路徑(白名單)</th>
                    <td>
                        <textarea class="large-text code" rows="4" name="psl_allowed_paths" placeholder="/var/www/mysite/wp-content/uploads/code-snippets
/data/shared/snippets"><?php
                            echo esc_textarea(implode("\n", (array)$opts['allowed_paths']));
                        ?></textarea>
                        <p class="description">每行一個絕對路徑;僅加入存在的資料夾。若留空則沿用上傳目錄中的 <code>code-snippets/</code>(已相容)。</p>
                    </td>
                </tr>
            </table>
            <?php submit_button('儲存進階設定'); ?>
        </form>

        <hr>

        <h2>檔案管理(code-snippets)</h2>
        <p>儲存位置: <code><?php echo esc_html($real_dir); ?></code></p>
        <p style="margin-bottom:10px;">
            <form method="post" style="display:inline;">
                <?php wp_nonce_field('psl_htaccess_nonce'); ?>
                <input type="hidden" name="psl_action" value="psl_gen_htaccess">
                <input type="submit" class="button" value="生成 .htaccess">
            </form>
            <form method="post" style="display:inline;margin-left:10px;">
                <?php wp_nonce_field('psl_htaccess_nonce'); ?>
                <input type="hidden" name="psl_action" value="psl_del_htaccess">
                <input type="submit" class="button" value="刪除 .htaccess">
            </form>
            <form method="post" style="display:inline;margin-left:10px;">
                <?php wp_nonce_field('psl_htaccess_nonce'); ?>
                <input type="hidden" name="psl_action" value="psl_testpath">
                <input type="submit" class="button" value="路徑解析測試">
            </form>
            <span style="margin-left:12px;">.htaccess 狀態:<?php echo $has_ht ? '<span style="color:green">存在</span>' : '<span style="color:gray">不存在</span>'; ?></span>
        </p>

        <div id="psl-fm" style="display:flex;gap:20px;align-items:flex-start;">
            <!-- 左側:資料夾樹 -->
            <div style="flex:0 0 280px;">
                <h3>資料夾</h3>
                <div class="psl-tree" id="psl-tree" style="border:1px solid #ddd;padding:8px;border-radius:6px;max-height:420px;overflow:auto;">
                    <?php echo psl_render_tree_html($tree, ''); ?>
                </div>

                <!-- 新增資料夾 -->
                <form method="post" style="margin-top:12px;">
                    <?php wp_nonce_field('psl_mkdir_nonce'); ?>
                    <input type="hidden" name="psl_action" value="psl_mkdir">
                    <input type="hidden" id="psl_current_input" name="psl_current" value="">
                    <label>在目前資料夾新增:<br>
                        <input type="text" name="psl_folder" placeholder="新資料夾名稱" class="regular-text">
                    </label>
                    <p style="margin-top:6px;"><button class="button">建立資料夾</button></p>
                </form>

                <!-- 刪除資料夾(會顯示名稱) -->
                <form method="post" style="margin-top:12px;">
                    <?php wp_nonce_field('psl_rmdir_nonce'); ?>
                    <input type="hidden" name="psl_action" value="psl_rmdir">
                    <input type="hidden" id="psl_current_delete" name="psl_current" value="">
                    <p><strong id="psl-delete-caption">刪除目前資料夾</strong></p>
                    <label>
                        <input type="checkbox" name="psl_recursive" value="1">
                        同時刪除內容(遞迴刪除)
                    </label>
                    <p style="margin-top:6px;">
                        <button class="button button-secondary" onclick="return confirm('確定要刪除所選資料夾?');">刪除資料夾</button>
                    </p>
                    <p class="description">無法刪除根目錄;不勾選時僅允許刪除空目錄。</p>
                </form>
            </div>

            <!-- 右側:上傳 + 檔案清單 -->
            <div style="flex:1 1 auto;">
                <h3>上傳檔案</h3>
                <form method="post" enctype="multipart/form-data" id="psl-upload-form">
                    <?php wp_nonce_field('psl_upload_nonce'); ?>
                    <input type="hidden" name="psl_action" value="psl_upload">
                    <input type="hidden" id="psl_current_upload" name="psl_current" value="">
                    <table class="form-table">
                        <tr>
                            <th>目前路徑</th>
                            <td><code id="psl-current-path">(根目錄)</code></td>
                        </tr>
                        <tr>
                            <th>檔案</th><td><input type="file" name="psl_file" required></td>
                        </tr>
                        <tr>
                            <th>描述</th>
                            <td><textarea name="psl_desc" class="large-text code" rows="3" placeholder="為這個檔案新增描述(可空白)"></textarea></td>
                        </tr>
                        <tr>
                            <th>重複檔名</th>
                            <td><label><input type="checkbox" name="psl_overwrite" value="1"> 覆蓋(不勾選則自動加上時間戳以避免覆蓋)</label></td>
                        </tr>
                    </table>
                    <?php submit_button('上傳'); ?>
                </form>

                <h3 style="margin-top:1.5em">檔案清單</h3>
                <p>可在下方直接刪除或編輯描述;點選左側資料夾可篩選。</p>

                <table class="widefat striped" id="psl-file-table">
                    <thead><tr><th>檔名(相對路徑)</th><th style="width:12%">大小</th><th style="width:18%">修改時間</th><th>描述</th><th style="width:120px">動作</th></tr></thead>
                    <tbody>
                        <?php if (!empty($files)): foreach ($files as $f): 
                            $rel = $f['rel'];
                            $desc = isset($meta[$rel]) ? $meta[$rel] : '';
                        ?>
                        <tr data-rel="<?php echo esc_attr($rel); ?>">
                            <td><code><?php echo esc_html($rel); ?></code></td>
                            <td><?php echo esc_html(size_format($f['size'])); ?></td>
                            <td><?php echo esc_html(date('Y-m-d H:i:s', $f['mtime'])); ?></td>
                            <td>
                                <form method="post" class="psl-desc-form" style="display:flex;gap:6px;align-items:flex-start;">
                                    <?php wp_nonce_field('psl_desc_nonce'); ?>
                                    <input type="hidden" name="psl_action" value="psl_update_desc">
                                    <input type="hidden" name="psl_path" value="<?php echo esc_attr($rel); ?>">
                                    <textarea name="psl_desc_edit" class="regular-text code" rows="2" style="width:100%;"><?php echo esc_textarea($desc); ?></textarea>
                                    <button class="button">儲存</button>
                                </form>
                            </td>
                            <td>
                                <form method="post" style="display:inline;">
                                    <?php wp_nonce_field('psl_delete_nonce'); ?>
                                    <input type="hidden" name="psl_action" value="psl_delete_file">
                                    <input type="hidden" name="psl_path" value="<?php echo esc_attr($rel); ?>">
                                    <input type="submit" class="button-link delete-link" value="刪除" onclick="return confirm('確定要刪除嗎?');">
                                </form>
                            </td>
                        </tr>
                        <?php endforeach; else: ?>
                        <tr><td colspan="5">目前資料夾中沒有檔案。</td></tr>
                        <?php endif; ?>
                    </tbody>
                </table>

            </div>
        </div>
    </div>

    <style>
    .psl-tree ul{ list-style:none; margin:0 0 0 14px; padding:0; }
    .psl-tree li{ margin:2px 0; }
    .psl-tree .folder{ cursor:pointer; user-select:none; }
    .psl-tree .folder:before{ content:"📁 "; }
    .psl-tree .file:before{ content:"📄 "; }
    .psl-tree .active{ background:#f0f6ff; border-radius:4px; }
    #psl-file-table tbody tr[hidden]{ display:none; }
    </style>

    <script>
    (function(){
        // 點選資料夾 → 設定目前路徑、篩選清單、同步上傳/新增/刪除資料夾表單
        const tree = document.getElementById('psl-tree');
        const curPathText = document.getElementById('psl-current-path');
        const curInput = document.getElementById('psl_current_input');
        const curUpload = document.getElementById('psl_current_upload');
        const curDelete = document.getElementById('psl_current_delete'); // 刪除資料夾表單
        const delCaption = document.getElementById('psl-delete-caption'); // 顯示「刪除目前資料夾(名稱)」
        const rows = document.querySelectorAll('#psl-file-table tbody tr');

        function folderCaption(path){
            if (!path) return '刪除目前資料夾(根目錄不可刪)';
            return '刪除目前資料夾(' + path + ')';
        }

        function selectFolder(path){
            // 高亮
            tree.querySelectorAll('.folder').forEach(el => el.classList.remove('active'));
            const node = tree.querySelector('[data-path="'+path+'"]');
            if (node) node.classList.add('active');

            // 顯示目前路徑
            curPathText.textContent = path ? path : '(根目錄)';
            curInput.value = path;
            curUpload.value = path;
            if (curDelete) curDelete.value = path; // 同步到刪除表單
            if (delCaption) delCaption.textContent = folderCaption(path);

            // 篩選表格
            rows.forEach(tr => {
                const rel = tr.getAttribute('data-rel');
                if (!path) { tr.hidden = false; return; }
                tr.hidden = !(rel.startsWith(path + '/') || rel === path);
            });
        }

        // 樹狀事件代理
        tree.addEventListener('click', function(e){
            const li = e.target.closest('.folder');
            if (!li) return;
            const path = li.getAttribute('data-path') || '';
            const sub = li.nextElementSibling; // UL
            if (sub && sub.tagName === 'UL') sub.hidden = !sub.hidden;
            selectFolder(path);
        });

        // 預設選根目錄
        selectFolder('');

        // 讓左樹第二層預設展開
        tree.querySelectorAll('.psl-tree ul ul').forEach(ul => ul.hidden = false);
    })();
    </script>
    <?php
}

/** ========= 產生樹狀 HTML ========= */
function psl_render_tree_html($node, $prefix) {
    // $node: ['__type'=>'dir','__children'=>[ name => (dir|file), ... ]]
    if (!is_array($node) || ($node['__type'] ?? '') !== 'dir') return '';
    $html = '<ul>';
    // 根目錄按鈕
    if ($prefix === '') {
        $html .= '<li><span class="folder active" data-path="">(根目錄)</span></li>';
    }
    // 排序:資料夾在前、檔案在後
    $dirs = $files = [];
    foreach (($node['__children'] ?? []) as $name => $item) {
        if (is_array($item) && ($item['__type'] ?? '') === 'dir') $dirs[$name] = $item;
        else $files[$name] = $item;
    }
    ksort($dirs, SORT_NATURAL|SORT_FLAG_CASE);
    ksort($files, SORT_NATURAL|SORT_FLAG_CASE);

    foreach ($dirs as $name => $child) {
        $path = ltrim($prefix . '/' . $name, '/');
        $html .= '<li><span class="folder" data-path="'.esc_attr($path).'">'.esc_html($name).'</span>';
        $html .= psl_render_tree_html($child, $path);
        $html .= '</li>';
    }
    foreach ($files as $name => $child) {
        $path = ltrim($prefix . '/' . $name, '/');
        $html .= '<li><span class="file" data-path="'.esc_attr($path).'">'.esc_html($name).'</span></li>';
    }
    $html .= '</ul>';
    return $html;
}
分類: 部落格架設,標籤: 。這篇內容的永久連結

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *