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 執行與索引。 |
路徑測試 | 顯示伺服器實際路徑,利於除錯。 |
三、安裝方式
- 將外掛資料夾
prism-smart-lite
放入/wp-content/plugins/
。 - 登入 WordPress → 外掛 → 啟用 Prism Smart Lite。
- 啟用後系統會自動建立
/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;
}