本文记录了在一次 Typecho 建站维护中的完整排查与实施过程:让两个独立页面分别只显示指定标签下的文章,过程中顺带修掉了主题在 Typecho 新版本下的一个归档页 Bug。全程仅通过 FTP 操作,未改动数据库、未改动 Typecho 核心。

一、需求与环境

  • 站点由 Typecho 1.3.0 搭建,主题为 Matcha 1.2.0(BigCoke233/matcha)
  • 站点上已有两个空白页面:diary.html(日记)和 ms.html(迷思)
  • 需求:两个页面分别只显示标签为「日记」和「迷思」的文章

手里只有一个 FTP 账号,没有数据库权限,也没有后台管理权限(但不影响本文方案,因为最终方案根本不需要动后台)。

二、关键发现:Typecho 支持按 slug 匹配页面模板

很多人以为自定义页面模板必须在后台"自定义模板"下拉框里选(那需要写数据库),其实翻一下 var/Widget/Archive.phprender() 方法就会发现,Typecho 1.2+ 的模板解析顺序是:

1
2
3
4
5
6
7
8
9
10
11
12
// 1. 后台指定的自定义模板 ($this->themeFile)
// 2. 按 slug 精确匹配:page/{slug}.php ← 关键!
if (!empty($this->archiveSlug)) {
$themeFile = $this->archiveType . '/' . $this->archiveSlug . '.php';
if (file_exists($this->themeDir . $themeFile)) {
$this->themeFile = $themeFile;
$valid = true;
}
}
// 3. page.php
// 4. single.php(页面属于 single)
// 5. index.php

也就是说:只要在主题目录下新建 page/diary.phppage/ms.php,对应 slug 的页面就会自动使用它们,完全不用碰数据库。

三、主题目录 FTP 不可写

动手时才发现:usr/themes/matcha/ 整个目录属主是 root:root、权限 755,FTP 账号写入直接被拒(553 Permission denied):

1
2
> STOR _test.php
< 553 Can't open that file: Permission denied

但排查各级目录权限后发现,上级目录 usr/themes/777。于是有了迁移方案:

  1. 通过 FTP 重命名(RNFR/RNTO 只需要父目录写权限):matchamatcha.bak(完整备份)
  2. 新建 matcha/ 目录,把主题文件完整迁移回去
  3. 在新目录里追加 page/diary.phppage/ms.php

由于新目录由 FTP 账号属主创建,后续修改主题文件(比如下文的 Bug 修复)也畅通无阻。

四、按标签调用文章的正确姿势

4.1 Widget 参数如何传给 tagHandle

Widget_ArchivetagHandle() 是从 request 对象 读取标签标识的:

1
2
if ($this->request->is('mid')) { ... }    // 按 mid 查
if ($this->request->is('slug')) { ... } // 按 slug 查

Typecho\Widget::widget() 的第三个参数会作为 request 参数注入(工厂方法内部会构造 WidgetRequest(Request::getInstance(), new Config($request)))。所以正确写法是第三个参数传 slug

1
2
3
4
5
$tagPosts = $this->widget(
'Widget_Archive@tag_diary', // @别名:避免与当前页面 widget 冲突
'type=tag&pageSize=30', // 组件参数:type=tag 会分发到 tagHandle
['slug' => '日记'] // request 参数:tagHandle 从这里读 slug
);

4.2 容错处理

如果标签还不存在,tagHandle 会抛出 WidgetException('标签不存在', 404)。站点初期可能还没给文章打标签,所以模板里用 try/catch 兜底:

1
2
3
4
5
6
$tagPosts = null;
try {
$tagPosts = $this->widget('Widget_Archive@tag_diary', 'type=tag&pageSize=30', ['slug' => '日记']);
} catch (\Throwable $e) {
$tagPosts = null;
}

4.3 模板核心结构(page/diary.php)

保留页面本身的标题和内容,下方按主题原有的文章列表样式渲染:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
<?php if (!defined('__TYPECHO_ROOT_DIR__')) exit;
$this->need('includes/header.php'); ?>
<div class="col-12" id="main" role="main">
<article class="post post-atpage">
<div class="post-header">
<h1 class="post-title post-title-atpage page-title-atpage"><?php $this->title(); ?></h1>
</div>
<?php ob_start(); $this->content(); $pageContent = trim(ob_get_clean()); ?>
<?php if ('' !== $pageContent): ?>
<div class="post-content"><?php echo $pageContent; ?></div>
<?php endif; ?>
</article>

<?php
$tagPosts = null;
try {
$tagPosts = $this->widget('Widget_Archive@tag_diary', 'type=tag&pageSize=30', ['slug' => '日记']);
} catch (\Throwable $e) { $tagPosts = null; }
?>
<section class="latest-post archive-post">
<?php if ($tagPosts && $tagPosts->have()): ?>
<?php while ($tagPosts->next()): ?>
<article class="post post-atmain">
<h2 class="post-title">
<a href="<?php $tagPosts->permalink(); ?>"><?php $tagPosts->title(); ?></a>
</h2>
<ul class="post-meta">
<li><?php echo Matcha::date($tagPosts->created); ?></li>
<li><?php $tagPosts->category(','); ?></li>
<li><?php $tagPosts->commentsNum('0 评论', '1 评论', '%d 评论'); ?></li>
</ul>
<div class="post-content"><?php Matcha::excerpt($tagPosts); ?></div>
</article>
<?php endwhile; ?>
<div class="page-navigator">
<span id="previous"><?php $tagPosts->pageLink('<span class="iconfont">&#xe749;</span>'); ?></span>
<span id="next"><?php $tagPosts->pageLink('<span class="iconfont">&#xe749;</span>', 'next'); ?></span>
</div>
<?php else: ?>
<p>还没有标记为「日记」的文章。</p>
<?php endif; ?>
</section>
</div>
<?php $this->need('includes/footer.php'); ?>

page/ms.php 完全同理,把别名换成 tag_ms、slug 换成 迷思 即可。

4.4 验证结果

  • 两个页面返回 200,正常渲染;标签不存在时显示空状态,不报错
  • 给文章打上对应标签后,列表自动出现(中文标签的 slug 默认就是中文本身,如「日记」)
  • 首页、归档页等其余页面不受影响

五、修复归档页的 permalink Warning

验证时发现主题的"往鉴"(归档)页面一直有个 Warning:

1
Warning: Undefined array key "permalink" in .../themes/matcha/libs/Matcha.php on line 402

5.1 根因

主题的 Matcha::archives()旧版 Typecho 的习惯写法取链接:

1
2
3
4
5
$row = $widget->filter($row);
$arr = array(
'title' => $row['title'],
'permalink' => $row['permalink'] // ← 新版 Typecho 里这个键不存在了
);

翻核心源码确认:新版 Widget\Base\Contents::filter() 只处理 title/text/slug/password/date不再把 permalink 写进返回数组;permalink 改成了魔术属性,由路由动态生成:

1
2
3
4
5
6
7
8
9
protected function ___path(): string
{
return Router::url($this->type, $this); // $this 实现了 ParamsDelegateInterface
}

protected function ___permalink(): string
{
return Common::url($this->path, $this->options->index);
}

所以这不仅是 Warning,还意味着归档页所有文章链接都是空的<a href="">)——一个实打实的功能 Bug。

5.2 修复

Router::url() 的签名:第二个参数既支持数组,也支持 ParamsDelegateInterface(内部按路由 pattern 的参数名取值)。核心的 Contents::getRouterParam() 覆盖了 cid/slug/directory/category/year/month/day。照此逻辑在主题里复刻一份即可:

1
2
// Matcha.php
'permalink' => $row['permalink'] ?? self::permalink($row) // 新旧版本兼容
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/**
* 生成内容链接(兼容新版 Typecho:permalink 改为魔术属性)
*/
private static function permalink(array $row): string
{
$delegate = new class($row) implements \Typecho\Router\ParamsDelegateInterface {
private $row;

public function __construct(array $row)
{
$this->row = $row;
}

public function getRouterParam(string $key): string
{
switch ($key) {
case 'cid': return (string) $this->row['cid'];
case 'slug': return urlencode($this->row['slug']);
case 'year': return date('Y', $this->row['created']);
case 'month': return date('m', $this->row['created']);
case 'day': return date('j', $this->row['created']);
default: return '{' . $key . '}';
}
}
};

$path = \Typecho\Router::url($row['type'], $delegate);

return \Typecho\Common::url($path, Helper::options()->index);
}

上传后验证:

  • Warning 消失
  • 归档条目链接正确生成(/index.php/archives/10/,与首页一致)
  • 首页等其余页面无报错

六、踩坑小结

  1. Typecho 1.2+ 的 page/{slug}.php 精确模板机制鲜为人知,却是最省事的按页面定制方案——不碰数据库、不改核心,主题目录下加文件即可。
  2. Widget::widget() 的第三个参数是 request 参数。想在外部(模板/插件)按标签、分类调用文章,slug/mid 要从第三个参数传,不是写在第二个参数里。
  3. 给别名 Widget 传参Widget_Archive@别名 的调用方式可以安全地与当前页面 Widget 并存。
  4. FTP 权限问题的绕行方案:目标目录不可写但父目录可写时,"重命名走人 → 重建目录 → 迁移回来"是只靠 FTP 就能完成的标准操作(RNFR/RNTO 只需要父目录写权限)。
  5. 升级 Typecho 后主题报警,先看核心 API 是否变了filter() 返回的数组里少掉的键,很可能已经变成了魔术属性(___xxx()),照抄核心的生成逻辑最稳。
  6. 迁移/修改前先留好完整备份(本次是 matcha.bak/),随时可回滚。

参考文献

  1. SegmentFault:《Typecho 如何在页面中显示指定标签的文章》问答(本文方案的出处思路)
    https://segmentfault.com/q/1010000047290321/a-1020000047291068
  2. Typecho 1.3.0 核心源码(本文分析所依据的服务器上实际代码)

    • var/Widget/Archive.php —— render() 模板解析顺序(page/{slug}.php 精确匹配)、tagHandle() 标签查询逻辑
    • var/Typecho/Widget.php —— widget() 工厂方法,第三参数注入 request 参数
    • var/Typecho/Widget/Request.php —— WidgetRequest 参数代理与 is()/get() 实现
    • var/Widget/Base/Contents.php —— filter() 过滤器、___permalink() 魔术属性、getRouterParam() 路由参数
    • var/Typecho/Router.php —— Router::url() 对数组 / ParamsDelegateInterface 的支持
  3. Matcha 主题仓库
    https://github.com/BigCoke233/matcha
  4. Typecho 官网
    http://www.typecho.org/