WordPress |单篇三栏式阅读布局

在技术博客和开发文档中,清晰的结构对于阅读体验至关重要。类比 Google 开发者文档和各大开源框架(如 Vue、React)的官方文档,经典的三栏式布局被证明是最为高效的阅读形态:
左侧:同类文章/系列导航,方便在相关主题间快速穿梭。
中间:专注的正文阅读区域。
右侧:当前页面的大纲目录(TOC),支持滚动高亮与快速锚点跳转。
以下就来实现一个为 WordPress 定制的插件 —— HMW Doc Layout。其能自动将特定文章类型调整为三栏式布局。

一、 功能亮点

零配置:安装即用,自动解析文章层级并生成目录。
智能标题 ID 注入:自动提取正文中的 <h2> 和 <h3> 标签,若无 id 属性则自动编排生成。
同类文章识别:
优先识别 WordPress 自带的分类法(Taxonomy)。
若无分类,则通过智能正则匹配标题前缀(例如 Python | 基础教程),自动将同系列文章归组。
现代化 UI 与滚动监听:
基于 CSS Grid 布局,并配合 position: sticky 实现侧边栏随页面滚动的粘性定位。
利用浏览器的 IntersectionObserver API,实现右侧大纲随阅读位置自动高亮。
完美响应式适配:在窄屏(移动设备)下自动折叠为单列流式布局,并支持原生 <details> 折叠展开交互。

二、详细解释

1. 插件基础信息与初始化检查

<?php
/**
 * Plugin Name: HMW Doc Layout
 * Description: Lab(project)单篇页面三栏布局...
 * Version: 1.0.0
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

const HMW_DOC_POST_TYPE = 'project';

作用:定义插件头部信息,防止直接通过 URL 访问该 PHP 文件(ABSPATH 检查确保安全性)。
常量:定义了要应用此布局的 WordPress 文章类型(Post Type)为 project。

2. 左侧同类文章分组逻辑(hmw_doc_group_posts)

function hmw_doc_group_posts( WP_Post $post ) {
    ...
}

作用:获取与当前文章属于同一“分类”或“系列”的其他文章,构建左侧导航。

匹配逻辑:

  1. 优先匹配分类/标签:遍历当前文章公开的 Taxonomy(分类法),若有分类,则获取该分类下的其他文章(最多 200 篇),按菜单顺序和日期排序。
  2. 回退匹配标题前缀:如果文章没有分类,则通过正则匹配标题前缀(例如 Python | 基础教程 中的 Python),把相同前缀的文章归为一类。

返回值:返回分组名称、文章列表数组、以及是否需要剥离标题前缀的布尔值。

3. 右侧本页目录(TOC)生成逻辑 (hmw_doc_build_toc)

function hmw_doc_build_toc( $content ) {
    ...
}

作用:扫描文章正文中的 <h2> 和 <h3> 标签。

核心处理:

  • 检查标题是否有自带 id,如果没有,则自动生成一个形如 sec-1, sec-2 的 id 并注入到 HTML 标签中。
  • 将所有收集到的标题层级(Level)、id 和文本内容存入数组,用于在右侧渲染目录。

4. 正文包装与三栏 HTML 结构拼装 (hmw_doc_wrap_content)

function hmw_doc_wrap_content( $content ) {
    ...
}
add_filter( 'the_content', 'hmw_doc_wrap_content', 20 );

作用:挂载到 WordPress 的 the_content 过滤器上,在输出正文时将其包裹进三栏网格结构中。

逻辑控制:

  • 判断当前是否为目标文章类型的单篇页面,避免在首页或列表页生效。
  • 调用前面两个函数获取左侧同类文章和右侧目录。
  • 如果既没有同类文章又没有目录,则保持原样;否则生成带有 .hmw-doc 类的三栏 HTML 结构(nav 左侧、div 中间正文、aside 右侧目录)。

5. 样式设计 (hmw_doc_print_css)

function hmw_doc_print_css() {
    ...
}
add_action( 'wp_head', 'hmw_doc_print_css' );

作用:通过 wp_head 注入 CSS 样式,控制三栏布局的外观。

关键样式特点:

  • 网格布局:使用 CSS Grid (grid-template-columns: 240px minmax(0, 1fr) 220px;) 实现固定两侧、自适应中间的正文。
  • 粘性定位:左右两侧目录使用 position: sticky;,在页面滚动时会固定在视口顶部。
  • 响应式适配:当屏幕宽度小于 1023px 时,自动转为单列布局,并将左右目录变成可折叠的 <details> 元素,适应移动端阅读。

6. 前端交互脚本 (hmw_doc_print_js)

function hmw_doc_print_js() {
    ...
}
add_action( 'wp_footer', 'hmw_doc_print_js' );

作用:通过 wp_footer 注入 JavaScript 代码,增强前端体验。

核心功能:

  1. 移动端默认折叠:在窄屏设备下自动关闭左右目录的展开状态。
  2. 滚动高亮监听:利用现代浏览器的 IntersectionObserver API,当用户在阅读正文滚动页面时,自动检测当前进入视口的标题,并高亮右侧目录中对应的链接。

三、 完整源代码

新建一个 PHP 文件(以本站为例 hmw-doc-layout.php),将以下代码放入 WordPress 插件目录(wp-content/plugins/hmw-doc-layout/)中,在后台插件管理页面「激活」即可。

<?php
/**
 * Plugin Name: HMW Doc Layout
 * Description: Lab(project)单篇页面三栏布局:左侧「同类文章目录」,中间正文,右侧「本页目录」(类似 Google 开发者文档)。
 * Version: 1.0.0
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/* 要应用的文章类型(Lab 的 URL 是 /project/,所以类型是 project) */
const HMW_DOC_POST_TYPE = 'project';

/**
 * 取得「同类文章」。
 * 1) 优先用该文章已有的分类/标签(taxonomy);
 * 2) 没有的话,用标题前缀分组,例如「Python|…」「Lychee|…」。
 *
 * @return array [ 分组名称, 文章数组, 是否去掉标题前缀 ]
 */
function hmw_doc_group_posts( WP_Post $post ) {
	$args = array(
		'post_type'      => $post->post_type,
		'post_status'    => 'publish',
		'posts_per_page' => 200,
		'orderby'        => array(
			'menu_order' => 'ASC',
			'date'       => 'ASC',
		),
		'no_found_rows'  => true,
	);

	foreach ( get_object_taxonomies( $post->post_type, 'objects' ) as $tax ) {
		if ( ! $tax->public ) {
			continue;
		}
		$terms = get_the_terms( $post, $tax->name );
		if ( $terms && ! is_wp_error( $terms ) ) {
			$term              = reset( $terms );
			$args['tax_query'] = array(
				array(
					'taxonomy' => $tax->name,
					'field'    => 'term_id',
					'terms'    => $term->term_id,
				),
			);
			return array( $term->name, get_posts( $args ), false );
		}
	}

	if ( preg_match( '/^(.+?)\s*[||]/u', get_the_title( $post ), $m ) ) {
		$prefix = trim( $m[1] );
		$regex  = '/^' . preg_quote( $prefix, '/' ) . '\s*[||]/u';
		$all    = get_posts( $args );
		$same   = array();
		foreach ( $all as $p ) {
			if ( preg_match( $regex, get_the_title( $p ) ) ) {
				$same[] = $p;
			}
		}
		return array( $prefix, $same, true );
	}

	return array( '', array(), false );
}

/**
 * 给 h2/h3 补上 id,并收集目录。
 */
function hmw_doc_build_toc( $content ) {
	$toc = array();

	$content = preg_replace_callback(
		'/<h([23])([^>]*)>(.*?)<\/h\1>/isu',
		function ( $m ) use ( &$toc ) {
			$text = trim( wp_strip_all_tags( $m[3] ) );
			if ( '' === $text ) {
				return $m[0];
			}
			$attrs = $m[2];
			if ( preg_match( '/\sid=["\']([^"\']+)["\']/i', $attrs, $idm ) ) {
				$id = $idm[1];
			} else {
				$id     = 'sec-' . ( count( $toc ) + 1 );
				$attrs .= ' id="' . esc_attr( $id ) . '"';
			}
			$toc[] = array(
				'level' => (int) $m[1],
				'id'    => $id,
				'text'  => $text,
			);
			return '<h' . $m[1] . $attrs . '>' . $m[3] . '</h' . $m[1] . '>';
		},
		$content
	);

	return array( $content, $toc );
}

/**
 * 包装正文。
 */
function hmw_doc_wrap_content( $content ) {
	static $done = false;

	if ( $done || is_feed() || is_admin() || ! is_singular( HMW_DOC_POST_TYPE ) ) {
		return $content;
	}
	$post = get_post();
	if ( ! $post || get_queried_object_id() !== $post->ID ) {
		return $content;
	}
	$done = true;

	list( $content, $toc )               = hmw_doc_build_toc( $content );
	list( $label, $siblings, $strip )    = hmw_doc_group_posts( $post );

	$has_side = count( $siblings ) > 1;
	$has_toc  = count( $toc ) > 1;

	if ( ! $has_side && ! $has_toc ) {
		return $content;
	}

	$classes = 'hmw-doc alignwide';
	if ( ! $has_side ) {
		$classes .= ' no-side';
	}
	if ( ! $has_toc ) {
		$classes .= ' no-toc';
	}

	$out = '<div class="' . esc_attr( $classes ) . '">';

	/* 左:同类文章 */
	if ( $has_side ) {
		$out .= '<nav class="hmw-doc__side" aria-label="同类文章">';
		$out .= '<details class="hmw-doc__fold" open><summary>' . esc_html( $label ? $label : '同类文章' ) . '</summary>';
		if ( $label ) {
			$out .= '<p class="hmw-doc__label">' . esc_html( $label ) . '</p>';
		}
		$out .= '<ul>';
		foreach ( $siblings as $p ) {
			$title = get_the_title( $p );
			if ( $strip ) {
				$title = trim( preg_replace( '/^.+?\s*[||]\s*/u', '', $title, 1 ) );
			}
			$current = ( $p->ID === $post->ID );
			$out    .= '<li' . ( $current ? ' class="is-current"' : '' ) . '>';
			$out    .= '<a href="' . esc_url( get_permalink( $p ) ) . '"' . ( $current ? ' aria-current="page"' : '' ) . '>' . esc_html( $title ) . '</a></li>';
		}
		$out .= '</ul>';
		$archive = get_post_type_archive_link( $post->post_type );
		if ( $archive ) {
			$out .= '<p class="hmw-doc__back"><a href="' . esc_url( $archive ) . '">← 返回 Lab</a></p>';
		}
		$out .= '</details></nav>';
	}

	/* 中:正文 */
	$out .= '<div class="hmw-doc__main">' . $content . '</div>';

	/* 右:本页目录 */
	if ( $has_toc ) {
		$out .= '<aside class="hmw-doc__toc" aria-label="本页目录">';
		$out .= '<details class="hmw-doc__fold" open><summary>本页内容</summary>';
		$out .= '<p class="hmw-doc__label">本页内容</p><ul>';
		foreach ( $toc as $item ) {
			$out .= '<li class="lv' . (int) $item['level'] . '"><a href="#' . esc_attr( $item['id'] ) . '">' . esc_html( $item['text'] ) . '</a></li>';
		}
		$out .= '</ul></details></aside>';
	}

	$out .= '</div>';

	return $out;
}
add_filter( 'the_content', 'hmw_doc_wrap_content', 20 );

/**
 * 样式。
 */
function hmw_doc_print_css() {
	if ( ! is_singular( HMW_DOC_POST_TYPE ) ) {
		return;
	}
	?>
<style id="hmw-doc-css">
html { scroll-behavior: smooth; }

.hmw-doc {
	display: grid;
	grid-template-columns: 240px minmax(0, 1fr) 220px;
	gap: 2.5rem;
	align-items: start;
}
.hmw-doc.no-side { grid-template-columns: minmax(0, 1fr) 220px; }
.hmw-doc.no-toc  { grid-template-columns: 240px minmax(0, 1fr); }
.hmw-doc.no-side.no-toc { grid-template-columns: minmax(0, 1fr); }

.hmw-doc__main { min-width: 0; max-width: 780px; }
.hmw-doc__main h2,
.hmw-doc__main h3 { scroll-margin-top: 5rem; }

.hmw-doc__side,
.hmw-doc__toc {
	position: sticky;
	top: 2rem;
	max-height: calc(100vh - 4rem);
	overflow-y: auto;
	font-size: 0.875rem;
	line-height: 1.5;
}
body.admin-bar .hmw-doc__side,
body.admin-bar .hmw-doc__toc { top: 4rem; }

.hmw-doc__side { grid-column: 1; }
.hmw-doc__main { grid-column: 2; }
.hmw-doc__toc  { grid-column: 3; }
.hmw-doc.no-side .hmw-doc__main { grid-column: 1; }
.hmw-doc.no-side .hmw-doc__toc  { grid-column: 2; }

.hmw-doc ul { list-style: none; margin: 0; padding: 0; }
.hmw-doc__fold > summary { display: none; cursor: pointer; font-weight: 600; padding: 0.6rem 0; }
.hmw-doc__label {
	margin: 0 0 0.75rem;
	font-size: 0.75rem;
	font-weight: 600;
	letter-spacing: 0.04em;
	opacity: 0.6;
}

/* 左侧目录 */
.hmw-doc__side li a {
	display: block;
	padding: 0.4rem 0.75rem;
	border-left: 2px solid transparent;
	color: inherit;
	text-decoration: none;
	opacity: 0.75;
}
.hmw-doc__side li a:hover { opacity: 1; background: color-mix(in srgb, currentColor 6%, transparent); }
.hmw-doc__side li.is-current a {
	opacity: 1;
	font-weight: 600;
	border-left-color: currentColor;
	background: color-mix(in srgb, currentColor 8%, transparent);
}
.hmw-doc__back { margin-top: 1rem; font-size: 0.8125rem; }
.hmw-doc__back a { color: inherit; opacity: 0.6; text-decoration: none; }
.hmw-doc__back a:hover { opacity: 1; }

/* 右侧目录 */
.hmw-doc__toc ul { border-left: 1px solid color-mix(in srgb, currentColor 15%, transparent); }
.hmw-doc__toc li a {
	display: block;
	padding: 0.3rem 0.75rem;
	margin-left: -1px;
	border-left: 2px solid transparent;
	color: inherit;
	text-decoration: none;
	opacity: 0.65;
}
.hmw-doc__toc li.lv3 a { padding-left: 1.5rem; font-size: 0.8125rem; }
.hmw-doc__toc li a:hover { opacity: 1; }
.hmw-doc__toc li a.is-active { opacity: 1; font-weight: 600; border-left-color: currentColor; }

/* 窄屏:单列,两个目录折叠 */
@media (max-width: 1023px) {
	.hmw-doc,
	.hmw-doc.no-side,
	.hmw-doc.no-toc { display: block; }
	.hmw-doc__side,
	.hmw-doc__toc {
		position: static;
		max-height: none;
		margin-bottom: 1.25rem;
		border-bottom: 1px solid color-mix(in srgb, currentColor 15%, transparent);
	}
	.hmw-doc__fold > summary { display: block; }
	.hmw-doc__fold .hmw-doc__label { display: none; }
	.hmw-doc__main { max-width: none; }
}
</style>
	<?php
}
add_action( 'wp_head', 'hmw_doc_print_css' );

/**
 * 脚本:窄屏默认折叠 + 右侧目录滚动高亮。
 */
function hmw_doc_print_js() {
	if ( ! is_singular( HMW_DOC_POST_TYPE ) ) {
		return;
	}
	?>
<script id="hmw-doc-js">
(function () {
	var doc = document.querySelector('.hmw-doc');
	if (!doc) return;

	if (window.matchMedia('(max-width: 1023px)').matches) {
		doc.querySelectorAll('details.hmw-doc__fold').forEach(function (d) {
			d.removeAttribute('open');
		});
	}

	var links = [].slice.call(doc.querySelectorAll('.hmw-doc__toc a'));
	if (!links.length || !('IntersectionObserver' in window)) return;

	var map = {};
	var heads = [];
	links.forEach(function (a) {
		var id = decodeURIComponent(a.getAttribute('href').slice(1));
		map[id] = a;
		var h = document.getElementById(id);
		if (h) heads.push(h);
	});

	function setActive(id) {
		links.forEach(function (a) { a.classList.remove('is-active'); });
		if (map[id]) map[id].classList.add('is-active');
	}

	var io = new IntersectionObserver(function (entries) {
		entries.forEach(function (e) {
			if (e.isIntersecting) setActive(e.target.id);
		});
	}, { rootMargin: '0px 0px -75% 0px' });

	heads.forEach(function (h) { io.observe(h); });
	if (heads.length) setActive(heads[0].id);
})();
</script>
	<?php
}
add_action( 'wp_footer', 'hmw_doc_print_js' );

使用与定制

修改文章类型:如果你想将此布局应用到默认的 post(文章)或其他自定义文章类型,只需修改代码开头的常量:

const HMW_DOC_POST_TYPE = 'post'; // 改为你的目标 Post Type

主题配合:建议配合 WordPress 主题的宽幅布局(alignwide 支持)使用,效果更佳。

标题规范:在编写文章时,保持良好的 H2、H3 语义化层级,插件便会自动生成精准的右侧导航目录。