# 03 · 布局与模板骨架

> 适用：写任何模板文件、调整页面结构、核对响应式与可访问性。
> 相关分册：类名为什么必须重新构建见 `02-样式与构建.md`；模块清单见 `04-能力模块清单.md`；可复制片段见 `05-代码片段库.md`。

---

## 一、页面层级与「每页仅一个 h1」

**硬性规定：每个页面有且只有一个 `<h1>`**，用来说明「这一页是什么」。其余标题从 `h2` 开始向下，不得跳级、不得为了字号而用标题标签（要小字用 `text-sm` 之类的工具类）。

| 模板 | 页面类型 | `h1` 落点（与参考实现一致） |
| --- | --- | --- |
| `front-page.php` | 首页（工具型） | 主功能区标题：`<h1 class="card-title">临时邮箱工作台</h1>`；同页第二块用 `<h2 class="card-title">我的临时邮箱</h2>` |
| `home.php` | 文章列表页 | `<h1 class="text-2xl font-bold"><?php single_post_title(); ?></h1>` |
| `archive.php` | 分类/标签/日期/作者归档 | 分类标签用 `<h1 class="text-2xl font-bold"><?php echo esc_html( single_term_title( '', false ) ); ?></h1>`；其他归档用 `the_archive_title( '<h1 class="text-2xl font-bold">', '</h1>' )` |
| `search.php` | 搜索结果 | `<h1 class="text-2xl font-bold">搜索结果：<?php echo esc_html( $xy_search_query ); ?></h1>` |
| `single.php` | 文章详情 | `<h1 class="card-title text-2xl"><?php the_title(); ?></h1>`（正文内的小标题由编辑器输出 `h2/h3`） |
| `page.php` | 单页面 | `<h1 class="card-title"><?php the_title(); ?></h1>` |
| `404.php` | 404 | `<h1 class="card-title">页面不存在</h1>` |
| `index.php` | 兜底列表 | 两种情形不冲突：**参考实现的 `index.php` 作为兜底不输出 h1**（h1 由 `home.php` / `archive.php` / `search.php` 各自承担，本页仅复用 `template-parts/post-list.php`，列表卡片标题用 `h2`）；**骨架版 `templates/index.php` 不同**——它是「项目没有 `home.php` 时唯一的列表页」，作为通用兜底会输出 h1（`is_home()` 时为 `<h1 class="text-2xl font-bold">最新文章</h1>`，其它兜底请求走 `the_archive_title()`），同一项目里存在 `home.php` 时 `index.php` 不会被使用，因此两者并存 |
| `comments.php` | 评论区（被 `single.php` 引入） | 不输出 h1；用 `<h2 class="card-title text-lg">评论 …</h2>` |
| `template-parts/related-posts.php` | 侧栏模块 | 不输出 h1；用 `<h2 id="xy-related-heading" class="card-title text-base">相关文章</h2>`，条目用 `h3` |

排查命令（浏览器控制台 / `evaluate_script`）：

```js
document.querySelectorAll('h1').length;  // 期望 1
[...document.querySelectorAll('h1')].map(h => h.textContent.trim());
```

---

## 二、容器宽度与间距约定

| 页面 | 容器类名 | 理由 |
| --- | --- | --- |
| 文章详情 `single.php` | `container mx-auto max-w-6xl px-4 py-8` | **必须 `max-w-6xl`**：两栏布局要给右侧栏留出 20rem（320px），正文列才能保住约 768px 的阅读宽度 |
| 文章列表 / 归档 / 搜索 / 兜底 | `container mx-auto max-w-6xl px-4 py-8 space-y-6` | 列表要容纳 `lg:grid-cols-3` 三列卡片，`max-w-6xl` 才有足够列宽 |
| 单页面 `page.php` / 404 | `container mx-auto max-w-3xl px-4 py-8` | 纯阅读/提示内容，单栏窄容器更易读，无需给侧栏留位 |
| 工具型首页 `front-page.php` | `container mx-auto max-w-3xl px-4 py-8 space-y-6` | 单列功能区，与单页保持同一阅读宽度 |

其他约定：

- 外层统一用 `mx-auto` + `px-4`（左右安全边距）+ `py-8`（上下留白）。
- 同一页多个区块用竖排间距：容器上加 `space-y-6`（参考实现用在列表页、首页），或区块自己带 `mt-*`。
- 卡片统一 `card bg-base-100 shadow-sm`，内容包一层 `card-body`；正文容器固定类名 `xy-entry`（排版规则在 `assets/css/theme.css`）。
- 列间距：两栏容器 `gap-6`，`lg` 起用 `lg:gap-8`。

---

## 三、文章详情页：两栏 + sticky 侧栏（核心骨架）

```php
<?php
// single.php 骨架（省略取数与细节，完整版见 templates/single.php）
get_header();
?>
<div class="container mx-auto max-w-6xl px-4 py-8">
	<?php while ( have_posts() ) : the_post(); ?>
		<div class="flex flex-col gap-6 lg:grid lg:grid-cols-[minmax(0,1fr)_20rem] lg:gap-8">

			<?php // 左栏第 1 行：正文 + 上下篇导航。min-w-0 防止长内容把网格列撑破。 ?>
			<div class="min-w-0 lg:col-start-1 lg:row-start-1">
				<article class="card bg-base-100 shadow-sm">
					<div class="card-body">
						<?php // 面包屑（见第四节） ?>
						<h1 class="card-title text-2xl"><?php the_title(); ?></h1>
						<div class="xy-entry"><?php the_content(); ?></div>
					</div>
				</article>

				<nav class="mt-6 flex flex-wrap items-center justify-between gap-2 text-sm" aria-label="文章导航">
					<span class="link link-hover"><?php previous_post_link( '%link', '上一篇：%title' ); ?></span>
					<span class="link link-hover"><?php next_post_link( '%link', '下一篇：%title' ); ?></span>
				</nav>
			</div>

			<?php
			// 右栏：跨两行（正文行 + 评论区行），因此其内部可以 sticky 悬浮整个正文区；
			// 侧栏模块直接加在下面这个 sticky 容器里即可（相关文章 / 目录 / 订阅 等）。
			?>
			<aside class="lg:col-start-2 lg:row-span-2 lg:row-start-1" aria-label="侧栏">
				<div class="space-y-4 lg:sticky lg:top-6 lg:max-h-[calc(100vh-3rem)] lg:overflow-y-auto">
					<?php get_template_part( 'template-parts/related-posts' ); ?>
				</div>
			</aside>

			<?php // 左栏第 2 行：评论区（评论列表 + 发表评论表单），模板为 comments.php。 ?>
			<div class="lg:col-start-1 lg:row-start-2">
				<?php comments_template(); ?>
			</div>
		</div>
	<?php endwhile; ?>
</div>
<?php get_footer();
```

### 类名逐个说明（不要凭感觉改写）

| 类名 | 作用 | 写错会怎样 |
| --- | --- | --- |
| `flex flex-col gap-6 lg:grid lg:grid-cols-[minmax(0,1fr)_20rem] lg:gap-8` | 窄屏纵向堆叠；`lg` 起切成两栏网格：左列弹性（`minmax(0,1fr)`）、右列固定 20rem（=320px） | 用 `grid-cols-2` 会让两列等宽，侧栏吃掉正文宽度 |
| `min-w-0`（左栏） | 允许弹性列收缩到内容最小宽度以下 | 长 URL / 宽表格会把列撑破，页面出现横向滚动 |
| `lg:col-start-1 lg:row-start-1` / `lg:row-start-2` | 把「正文」和「评论区」显式放到第 1、2 行同一列（DOM 上侧栏夹在两者之间，见下节） | 位置由自动排布决定，顺序会乱 |
| `lg:col-start-2 lg:row-span-2 lg:row-start-1` | 侧栏固定在第 2 列，**纵向跨两行**，起点为第 1 行 | 不写 `row-span-2`：侧栏只与正文行等高，评论区长出来后侧栏的可悬浮范围被截断，下方内容一滚过就「掉出」吸附 |
| `space-y-4`（内层） | 侧栏模块之间的统一竖向间距 | 模块贴在一起 |
| `lg:sticky lg:top-6` | `lg` 起内层容器吸附在距视口顶部 1.5rem（24px）处 | 直接挂在 `aside` 上：`aside` 被拉伸成两行高，吸附的是「空盒子」而不是内容块 |
| `lg:max-h-[calc(100vh-3rem)] lg:overflow-y-auto` | 视口内最大高度 = 100vh − 3rem（顶部 1.5rem 偏移 + 底部 1.5rem 留白），超出时侧栏**自身滚动** | 不加：模块多了侧栏会顶出视口，底部模块永远不可见，且吸附时间被拉长 |

**为什么 sticky 要放在 `aside` 的内层容器上**：`aside` 作为网格项被 `row-span-2` 拉伸到两行总高，用它是「占位 + 占列」；内层 `div` 高度是模块内容的自然高度，因而能在 `aside` 的整个高度范围内正常吸附与滑动。

**追加侧栏模块**：直接往内层 sticky 容器里加一行，不需要改任何类名（新模块要自带卡片外观）：

```php
<div class="space-y-4 lg:sticky lg:top-6 lg:max-h-[calc(100vh-3rem)] lg:overflow-y-auto">
	<?php get_template_part( 'template-parts/related-posts' ); ?>
	<?php get_template_part( 'template-parts/toc' ); // 新增模块照此追加 ?>
</div>
```

### 侧栏卡片写法（渐变描边卡片）

```php
<?php // 外层的 1px 渐变描边（品牌紫蓝 → 青蓝）+ 内层卡片，形成「特色边框」而无需自定义 CSS。 ?>
<div class="rounded-box bg-linear-to-br from-primary/60 via-secondary/30 to-info/60 p-px shadow-sm">
	<section class="card rounded-[calc(var(--radius-box)-1px)] bg-base-100" aria-labelledby="xy-related-heading">
		<div class="card-body gap-4">
			<h2 id="xy-related-heading" class="card-title text-base">
				<span class="inline-block h-4 w-1 rounded-full bg-linear-to-b from-primary to-info" aria-hidden="true"></span>
				相关文章
			</h2>
			<ul class="flex flex-col gap-3">
				<li>
					<a class="group flex items-start gap-3 rounded-box border-l-2 border-base-300/70 py-1.5 pr-1 pl-3 transition-colors hover:border-primary hover:bg-base-200/60" href="<?php the_permalink(); ?>">
						<figure class="aspect-[16/9] w-24 shrink-0 overflow-hidden rounded-box bg-base-200 ring-1 ring-base-300/70">
							<?php the_post_thumbnail( 'medium', array( 'class' => 'h-full w-full object-cover', 'loading' => 'lazy' ) ); ?>
						</figure>
						<div class="min-w-0 flex-1">
							<h3 class="text-sm leading-snug font-medium line-clamp-2 group-hover:text-primary"><?php the_title(); ?></h3>
							<time class="text-xs text-base-content/60" datetime="<?php echo esc_attr( get_the_date( 'c' ) ); ?>"><?php echo esc_html( get_the_date( 'Y-m-d' ) ); ?></time>
						</div>
					</a>
				</li>
			</ul>
		</div>
	</section>
</div>
```

要点：外层 `p-px` + 渐变背景做描边，内层用 `rounded-[calc(var(--radius-box)-1px)]`（比外层少 1px，正好贴合内缘）；标题前的竖条是 `h-4 w-1` 的渐变小方块 + `aria-hidden="true"`；每条用左边框 `border-l-2` + hover 变主色，缩略图块 `aspect-[16/9] w-24 shrink-0`。

### 响应式行为

| 视口 | 表现 |
| --- | --- |
| `lg` 及以上（≥ **1024px**） | 两栏：左列 ≈768px + 右列 320px；侧栏在滚动时吸附（距顶 24px） |
| `lg` 以下（< 1024px） | `flex flex-col` 单栏，顺序即 DOM 顺序：**正文 → 侧栏 → 评论区** |

**窄屏顺序由 DOM 顺序决定**（不是用 `order-*` 调出来的）：HTML 里先写「左栏第 1 行（正文）」→ 再写「`aside` 侧栏」→ 最后写「左栏第 2 行（评论区）」。桌面端靠 `lg:col-start-* / lg:row-start-*` 显式定位把侧栏挪到右列并跨行，窄屏时这些类名全部不生效，自然回落成上面的单栏顺序。

> 所以：**不要为了改窄屏顺序去动 DOM 顺序**，那会同时破坏桌面端定位；也不要给侧栏加 `order-first` 之类的类。

---

## 四、面包屑骨架与层级规则

```php
<?php
// 面包屑：首页 › 文章列表页（未设置时省略）› 首个分类（无分类时省略）› 当前文章。
$xy_posts_page_id   = (int) get_option( 'page_for_posts' );
$xy_post_categories = get_the_category();
?>
<nav class="breadcrumbs text-sm" aria-label="面包屑">
	<ul>
		<li><a href="<?php echo esc_url( home_url( '/' ) ); ?>">首页</a></li>
		<?php if ( $xy_posts_page_id ) : ?>
			<li><a href="<?php echo esc_url( get_permalink( $xy_posts_page_id ) ); ?>">文章</a></li>
		<?php endif; ?>
		<?php if ( ! empty( $xy_post_categories ) ) : ?>
			<li><a href="<?php echo esc_url( get_category_link( $xy_post_categories[0] ) ); ?>"><?php echo esc_html( $xy_post_categories[0]->name ); ?></a></li>
		<?php endif; ?>
		<li><span aria-current="page"><?php the_title(); ?></span></li>
	</ul>
</nav>
```

层级规则（固定四级，中间层按条件省略）：

| 层级 | 条件 | 数据来源 |
| --- | --- | --- |
| 1 首页 | 始终 | `home_url( '/' )` |
| 2 文章列表页 | 仅当后台「设置 → 阅读」指定了文章页 | `get_option( 'page_for_posts' )` + `get_permalink()` |
| 3 首个分类 | 仅当文章有分类 | `get_the_category()` 的第 0 项 + `get_category_link()` |
| 4 当前标题 | 始终 | `the_title()`，外层 `<span aria-current="page">`，**不加链接** |

其他要点：

- `nav` 必须带 `aria-label="面包屑"`（页面上可能同时存在「文章导航」等多个 `<nav>`）。
- daisyUI `breadcrumbs` 组件靠 `<ul><li>` 结构自动生成分隔符，不要手写 `›`。
- 面包屑放在 `card-body` 内、`h1` **之前**。
- 标签、日期等更多层级**不要**往面包屑里塞，保持四级内。

---

## 五、列表页骨架（列表 / 归档 / 搜索共用）

三级结构：**页头（h1 + 描述）→ 搜索表单 → 列表片段**。

```php
<?php // home.php 骨架 ?>
<div class="container mx-auto max-w-6xl px-4 py-8 space-y-6">
	<h1 class="text-2xl font-bold"><?php single_post_title(); ?></h1>
	<?php get_search_form(); ?>
	<?php get_template_part( 'template-parts/post-list' ); ?>
</div>
```

`template-parts/post-list.php` 本体（网格卡片 + 分页 + 空态）：

```php
<?php if ( have_posts() ) : ?>
	<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
		<?php while ( have_posts() ) : the_post(); ?>
			<article class="card bg-base-100 shadow-sm transition-shadow hover:shadow-md">
				<?php if ( has_post_thumbnail() ) : ?>
					<figure class="aspect-[16/9] overflow-hidden">
						<a class="block h-full w-full" href="<?php the_permalink(); ?>">
							<?php the_post_thumbnail( 'medium_large', array( 'class' => 'h-full w-full object-cover' ) ); ?>
						</a>
					</figure>
				<?php else : ?>
					<?php // 无特色图片时输出等高占位块，保证同一行卡片高度一致。 ?>
					<div class="flex aspect-[16/9] items-center justify-center bg-base-200 text-sm text-base-content/50">暂无封面</div>
				<?php endif; ?>
				<div class="card-body gap-2">
					<div class="flex flex-wrap items-center gap-2 text-sm text-base-content/70">
						<?php foreach ( get_the_category() as $xy_category ) : ?>
							<a class="badge badge-sm badge-outline" href="<?php echo esc_url( get_category_link( $xy_category ) ); ?>"><?php echo esc_html( $xy_category->name ); ?></a>
						<?php endforeach; ?>
						<time datetime="<?php echo esc_attr( get_the_date( 'c' ) ); ?>"><?php echo esc_html( get_the_date( 'Y-m-d' ) ); ?></time>
						<?php xy_mbx_comment_count_badge(); // 评论数徽标（0 条时不输出） ?>
					</div>
					<h2 class="card-title text-lg">
						<a class="link link-hover line-clamp-2" href="<?php the_permalink(); ?>"><?php the_title(); ?></a>
					</h2>
					<p class="text-sm text-base-content/70"><?php echo esc_html( wp_trim_words( get_the_excerpt(), 24, '…' ) ); ?></p>
					<a class="link link-primary text-sm" href="<?php the_permalink(); ?>">阅读全文 →</a>
				</div>
			</article>
		<?php endwhile; ?>
	</div>

	<?php
	the_posts_pagination(
		array(
			'prev_text' => '上一页',
			'next_text' => '下一页',
			'mid_size'  => 1,
		)
	);
	?>
<?php elseif ( is_search() ) : ?>
	<p class="py-8 text-center text-base-content/60">
		<?php echo esc_html( '没有找到与「' . get_search_query() . '」相关的内容，试试其他关键词。' ); ?>
	</p>
<?php else : ?>
	<p class="py-8 text-center text-base-content/60">暂无内容。</p>
<?php endif; ?>
```

要点：

- 片段**依赖调用方已在主循环上下文**（`have_posts()` / `the_post()` 可用），自己不再 `query_posts`。
- 卡片标题用 `h2`（页面级 `h1` 已被页头占用）。
- 无封面输出等高占位块（`aspect-[16/9]`），避免同行卡片高度参差。
- 摘要统一 `wp_trim_words( get_the_excerpt(), 24, '…' )` + `esc_html()`。
- 分页用 `the_posts_pagination( array( 'prev_text' => '上一页', 'next_text' => '下一页', 'mid_size' => 1 ) )`；外观由 `assets/css/theme.css` 的 `.page-numbers` 规则提供。
- 空态区分两种：搜索页带搜索词，其他列表页统一「暂无内容。」

---

## 六、归档 / 搜索 / 404 骨架要点

### 归档 `archive.php`

```php
<header class="space-y-2">
	<?php
	if ( is_category() || is_tag() ) {
		// 分类 / 标签归档：只显示术语名称，不带「分类：」「标签：」前缀。
		echo '<h1 class="text-2xl font-bold">' . esc_html( single_term_title( '', false ) ) . '</h1>';
	} else {
		// 日期、作者等其他归档：沿用 WordPress 生成的归档标题。
		the_archive_title( '<h1 class="text-2xl font-bold">', '</h1>' );
	}

	the_archive_description( '<div class="text-base-content/70">', '</div>' );
	?>
</header>
```

- 分类/标签**必须去掉「分类：」「标签：」前缀**（用 `single_term_title( '', false )` 自己包 `h1`），否则标题冗余。
- 术语描述用 `the_archive_description()`，套 `text-base-content/70`。
- 其余结构与列表页一致（搜索表单 + `post-list` 片段）。

### 搜索 `search.php`

```php
$xy_search_query = get_search_query();
$xy_found_posts  = (int) $wp_query->found_posts;
?>
<header class="space-y-2">
	<h1 class="text-2xl font-bold">搜索结果：<?php echo esc_html( $xy_search_query ); ?></h1>
	<p class="text-base-content/70">共找到 <?php echo esc_html( (string) $xy_found_posts ); ?> 篇内容</p>
</header>
```

- 命中数量取 `$wp_query->found_posts`（**全局 `$wp_query`**，不是 `get_queried_object()`）。
- 搜索词与数量都要 `esc_html()`。

### 404 `404.php`

```php
<div class="container mx-auto max-w-3xl px-4 py-8">
	<div class="card bg-base-100 shadow-sm">
		<div class="card-body items-center text-center">
			<h1 class="card-title">页面不存在</h1>
			<p class="text-base-content/70">你访问的页面可能已被删除，或地址输入有误。</p>
			<div class="card-actions mt-2">
				<a class="btn btn-primary" href="<?php echo esc_url( home_url( '/' ) ); ?>">返回首页</a>
			</div>
		</div>
	</div>
</div>
```

- 单栏窄容器（`max-w-3xl`）+ 居中卡片 + 一个主按钮返回首页。
- 不要输出搜索表单以外的复杂内容（404 页会被 SEO 插件自动 noindex）。

---

## 七、页头 / 页脚容器约定（`<main>` 的开合）

`header.php` 负责打开、`footer.php` 负责闭合，模板文件只填充 `<main>` 内部：

```php
<?php // header.php：末尾必须停在这里，<main> 不闭合 ?>
<body <?php body_class(); ?>>
<?php wp_body_open(); ?>
<div class="min-h-screen flex flex-col">
	<header class="navbar bg-base-200 px-4"> … 品牌 + 导航 … </header>
	<main class="flex-1">

<?php // footer.php：先闭合 <main> 与外层容器，再输出页脚，最后 wp_footer() ?>
	</main>
	<footer class="footer sm:footer-horizontal items-center bg-base-200 px-4 py-6 text-sm"> … 品牌 + 友情链接 + 版权 … </footer>
</div>
<?php wp_footer(); ?>
```

- `min-h-screen flex flex-col` + `main.flex-1` 保证内容少时页脚也贴在视口底部。
- `<head>` 内必须有 `<meta charset>`、`<meta name="viewport" content="width=device-width, initial-scale=1">`、图标 `<link rel="icon">`，然后调 `wp_head()`。
- 页头 `<head>` 只做这些；**不要**手写 `<title>`（交给 `add_theme_support( 'title-tag' )`）、也不要重复输出 SEO 插件的标签。

---

## 八、验收方法（浏览器读计算样式与几何值）

在浏览器里（chrome-devtools MCP 的 `evaluate_script`，或 DevTools 控制台）执行下面这段，一次性拿到四项结论：

```js
(() => {
  const wrap = document.querySelector('.lg\\:grid-cols-\\[minmax\\(0\\,1fr\\)_20rem\\]')
            || document.querySelector('main div[class*="lg:grid-cols-"]');
  const aside = document.querySelector('aside[aria-label="侧栏"]');
  const sticky = aside && aside.firstElementChild;
  const before = sticky ? Math.round(sticky.getBoundingClientRect().top) : null;

  window.scrollTo(0, 1200);
  const after = sticky ? Math.round(sticky.getBoundingClientRect().top) : null;
  window.scrollTo(0, 0);

  return {
    viewport: { w: innerWidth, h: innerHeight, breakpoint: innerWidth >= 1024 ? 'lg 两栏' : '单栏' },
    columns: wrap ? getComputedStyle(wrap).gridTemplateColumns : null,          // 1440 视口期望 "768px 320px"
    domOrder: wrap ? [...wrap.children].map(el => el.tagName + (el.getAttribute('aria-label') ? '[' + el.getAttribute('aria-label') + ']' : '')) : null,
    stickyPosition: sticky ? getComputedStyle(sticky).position : null,          // lg 下期望 "sticky"
    stickyTopAtPageTop: before,                                                 // 页顶时约 128
    stickyTopAfterScroll: after,                                                // 滚到 1200 后期望约 24（= lg:top-6）
    horizontalOverflow: { scrollWidth: document.documentElement.scrollWidth, clientWidth: document.documentElement.clientWidth },
    h1Count: document.querySelectorAll('h1').length                             // 期望 1
  };
})()
```

判定标准（与参考实现的实测值一致）：

| 项 | 判定 |
| --- | --- |
| 两栏成立 | 1440 视口下 `columns` = `768px 320px`（左列弹性 + 右列 20rem） |
| 侧栏 sticky 生效 | `stickyPosition` = `sticky`；页顶时 `stickyTopAtPageTop` ≈ 128；`scrollTo(0, 1200)` 后 `stickyTopAfterScroll` ≈ 24（等于 `lg:top-6` 的 1.5rem） |
| 窄屏顺序 | 375 视口下 `breakpoint` = `单栏`，`domOrder` 为 `DIV → ASIDE[侧栏] → DIV`，即**正文 → 侧栏 → 评论区** |
| 无横向溢出 | 各视口下 `scrollWidth == clientWidth`（1440 / 1024 / 768 / 375 都测） |
| h1 唯一 | `h1Count` = 1 |

切换视口用 `emulate`（或 DevTools 设备模拟）后重新执行同一段脚本即可；`lg` 断点为 1024px，建议实测 **1440 / 1024 / 768 / 375** 四档。
