Typecho 的插件系统是其扩展能力的核心。随着 1.3 版本全面引入 PHP 命名空间,插件开发的代码组织和类引用方式发生了显著变化。本文将手把手带你编写一个兼容 Typecho 1.3 的完整插件,涵盖从目录结构到钩子注册的全部流程。

一、插件目录结构

一个标准的 Typecho 1.3 风格插件应遵循以下结构:

usr/plugins/MyPlugin/
├── Plugin.php          # 插件主文件(入口)
├── Action.php          # 独立页面控制器(可选)
├── lib/
│   ├── Parser.php      # 业务逻辑类
│   └── Helper.php      # 工具函数
├── static/
│   ├── css/
│   │   └── style.css
│   └── js/
│       └── main.js
└── README.md

二、Plugin.php 核心文件

<?php
/**
 * MyPlugin - 示例插件
 * 
 * @package MyPlugin
 * @author YourName
 * @version 1.0.0
 * @link https://your-site.com
 */

namespace TypechoPlugin\MyPlugin;

use Typecho\Common;
use Typecho\Db;
use Typecho\Exception;
use Typecho\Plugin\Interface as PluginInterface;
use Typecho\Widget\Helper\Form;
use Typecho\Widget\Helper\Form\Element\Text;
use Typecho\Widget\Helper\Form\Element\Textarea;
use Typecho\Widget\Helper\Form\Element\Radio;
use Widget\Options;

if (!defined('__TYPECHO_ROOT_DIR__')) exit;

class Plugin implements PluginInterface
{
    /**
     * 激活插件
     */
    public static function activate()
    {
        // 注册内容处理钩子(双钩子机制)
        \Typecho\Plugin::factory('Widget\Abstract\Contents')->excerpt = [__CLASS__, 'parseExcerpt'];
        \Typecho\Plugin::factory('Widget\Abstract\Contents')->content = [__CLASS__, 'parseContent'];
        
        // 注册页脚输出钩子
        \Typecho\Plugin::factory('Widget\Archive')->footer = [__CLASS__, 'injectFooter'];
        
        // 创建数据库表(如果需要)
        self::createTable();
        
        return _t('MyPlugin 已激活,请在设置中配置参数');
    }

    /**
     * 禁用插件
     */
    public static function deactivate()
    {
        return _t('MyPlugin 已禁用');
    }

    /**
     * 插件配置面板
     */
    public static function config(Form $form)
    {
        $apiKey = new Text('apiKey', null, null, 
            _t('API Key'), 
            _t('请输入你的服务 API Key'));
        $form->addInput($apiKey->addRule('required', _t('API Key 不能为空')));

        $maxItems = new Text('maxItems', null, '10', 
            _t('最大条目数'), 
            _t('每页显示的最大条目数量'));
        $form->addInput($maxItems->addRule('isInteger', _t('必须为整数')));

        $enableCache = new Radio('enableCache', 
            ['0' => _t('关闭'), '1' => _t('开启')], 
            '1', 
            _t('启用缓存'));
        $form->addInput($enableCache);
        
        $customCss = new Textarea('customCss', null, null, 
            _t('自定义 CSS'), 
            _t('此处填写的 CSS 将被注入到页面头部'));
        $form->addInput($customCss);
    }

    /**
     * 个人配置面板(多用户模式)
     */
    public static function personalConfig(Form $form) {}

    /**
     * 摘要处理
     */
    public static function parseExcerpt($text, $widget)
    {
        $options = Options::widget();
        $pluginOptions = $options->plugin('MyPlugin');
        
        // 简单的字数截断逻辑
        $maxLen = intval($pluginOptions->maxItems ?? 10) * 20;
        if (mb_strlen(strip_tags($text), 'UTF-8') > $maxLen) {
            $text = mb_substr(strip_tags($text), 0, $maxLen, 'UTF-8') . '...';
        }
        
        return $text;
    }

    /**
     * 正文内容处理
     */
    public static function parseContent($text, $widget)
    {
        $options = Options::widget();
        $pluginOptions = $options->plugin('MyPlugin');
        
        // 为所有外链添加 target="_blank"
        $text = preg_replace(
            '/<a(.*?)href="(https?:\/\/[^"]+)"(.*?)>/i',
            '<a$1href="$2"$3 target="_blank" rel="noopener noreferrer">',
            $text
        );
        
        return $text;
    }

    /**
     * 页脚注入
     */
    public static function injectFooter()
    {
        $options = Options::widget();
        $pluginOptions = $options->plugin('MyPlugin');
        
        if (!empty($pluginOptions->customCss)) {
            echo '<style>' . htmlspecialchars($pluginOptions->customCss) . '</style>' . "\n";
        }
    }

    /**
     * 创建数据表
     */
    private static function createTable()
    {
        $db = Db::get();
        $adapter = $db->getAdapterName();
        
        if ('Pdo_Mysql' === $adapter || 'Mysqli' === $adapter) {
            $db->query("CREATE TABLE IF NOT EXISTS {$db->getPrefix()}myplugin_logs (
                id INT(10) UNSIGNED AUTO_INCREMENT PRIMARY KEY,
                action VARCHAR(50) NOT NULL,
                content TEXT,
                created INT(10) UNSIGNED DEFAULT 0
            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
        }
    }
}

三、双内容钩子机制详解

Typecho 1.3 引入了双内容钩子,允许插件在 excerptcontent 两个不同阶段介入:

// 注册到摘要钩子(列表页调用)
\Typecho\Plugin::factory('Widget\Abstract\Contents')->excerpt = [__CLASS__, 'excerptHook'];

// 注册到正文钩子(详情页调用)
\Typecho\Plugin::factory('Widget\Abstract\Contents')->content = [__CLASS__, 'contentHook'];

两个钩子接收相同的参数签名:

public static function excerptHook($text, $widget)
{
    // $text: 当前内容文本(Markdown 或 HTML)
    // $widget: 当前 Widget 实例,可通过它获取文章元数据
    
    $cid = $widget->cid;      // 文章 ID
    $title = $widget->title;  // 文章标题
    
    return $text;  // 必须返回处理后的文本
}

四、数据库操作示例

use Typecho\Db;

class Plugin implements PluginInterface
{
    public static function queryData()
    {
        $db = Db::get();
        $prefix = $db->getPrefix();
        
        // 查询最近发布的 5 篇文章
        $result = $db->fetchAll(
            $db->select()->from('table.contents')
                ->where('type = ?', 'post')
                ->where('status = ?', 'publish')
                ->order('created', Db::SORT_DESC)
                ->limit(5)
        );
        
        return $result;
    }
    
    public static function insertLog($action, $content = '')
    {
        $db = Db::get();
        $prefix = $db->getPrefix();
        
        $db->query(
            $db->insert('table.myplugin_logs')->rows([
                'action'  => $action,
                'content' => $content,
                'created' => time()
            ])
        );
    }
}

五、独立页面控制器(Action.php)

如果你的插件需要提供独立的前端页面(如一个自定义 API 接口),可以创建 Action.php

<?php
namespace TypechoPlugin\MyPlugin;

use Typecho\Widget;
use Widget\Options;

class Action extends Widget\Base\Contents
{
    public function action()
    {
        // 简单的 JSON API 示例
        $this->response->setHeader('Content-Type', 'application/json');
        
        $data = [
            'status' => 'ok',
            'time'   => date('c'),
            'posts'  => $this->getRecentPosts()
        ];
        
        echo json_encode($data);
    }
    
    private function getRecentPosts()
    {
        $db = \Typecho\Db::get();
        return $db->fetchAll(
            $db->select('cid', 'title', 'slug', 'created')
                ->from('table.contents')
                ->where('type = ?', 'post')
                ->where('status = ?', 'publish')
                ->order('created', \Typecho\Db::SORT_DESC)
                ->limit(5)
        );
    }
}

访问地址:https://your-blog.com/action/myplugin

六、配置持久化

Typecho 使用 options 表存储插件配置:

// 读取配置
$options = Options::widget();
$myConfig = $options->plugin('MyPlugin');
$value = $myConfig->apiKey;

// 配置值会自动序列化存储,支持数组和对象

七、调试技巧

// 1. 查看所有可用钩子
$hooks = \Typecho\Plugin::export();
var_dump($hooks);

// 2. 在插件中记录日志
error_log('MyPlugin: ' . print_r($data, true), 3, __DIR__ . '/debug.log');

// 3. 开启 Typecho 调试模式
// 在 config.inc.php 中定义:
// define('__TYPECHO_DEBUG__', true);

八、总结

Typecho 1.3 的命名空间化让插件开发更加规范化、现代化。虽然旧插件在兼容层保护下仍能运行,但新插件应当全面采用命名空间风格,以获得更好的可维护性和扩展性。


延伸阅读:Typecho 官方插件模板