# 02 · 样式与构建（Tailwind CSS 4 + daisyUI 5 预编译）

> 适用：新建主题的样式环境、任何一次「新增/修改类名」、样式不生效、从 WindPress 迁移。
> 一句话铁律：**改类名 → 必须 `npm run build:css` → 否则样式不生效。**

---

## 一、为什么用预编译静态产物

做法：开发期用 Tailwind CLI 把类名编译成**静态文件** `assets/css/tailwind.css`，随主题一起分发；前台只 `wp_enqueue_style()` 这个文件。

| 方案 | 首屏表现 | 运行期依赖 | 结论 |
| --- | --- | --- | --- |
| 浏览器端运行时编译（如 WindPress） | 先出裸 HTML，编译器下载并执行后才补样式 → **无样式闪烁（FOUC）** | 需要下载编译器、依赖 esm.sh 等外部 CDN | 不采用 |
| **预编译静态产物（本规范）** | HTML 与 CSS 同时到达，首屏即完整样式 | **无**：不需要 Node、不需要插件、不依赖任何外部 CDN | ✅ 锁定方案 |

附带好处：生产环境不需要安装 Node/npm，也不需要装 WindPress；样式与模板一起版本化，回滚主题即回滚样式。

---

## 二、可复制的构建配置

### `package.json`（放在主题根目录）

```json
{
  "name": "your-theme-name",
  "version": "1.0.0",
  "private": true,
  "description": "Tailwind CSS + daisyUI 构建配置：仅开发期使用，把类名预编译为 assets/css/tailwind.css，避免前台运行时编译导致的样式延迟。",
  "scripts": {
    "build:css": "tailwindcss -i ./assets/css/src/main.css -o ./assets/css/tailwind.css --minify",
    "watch:css": "tailwindcss -i ./assets/css/src/main.css -o ./assets/css/tailwind.css --watch"
  },
  "devDependencies": {
    "@tailwindcss/cli": "^4.3.3",
    "daisyui": "^5.7.38",
    "tailwindcss": "^4.3.3"
  }
}
```

### `assets/css/src/main.css`（构建入口，全文照抄）

```css
/**
 * 主题 Tailwind CSS 构建入口（源文件，不直接对外加载）
 *
 * 用途：由 `npm run build:css` 编译为 ../tailwind.css，主题在 functions.php 中直接引入该静态产物，
 * 因此前台不再依赖 WindPress 的浏览器端运行时编译，首屏即带完整样式（无样式闪烁）。
 *
 * 注意：
 * 1. 扫描范围用 @source 显式声明（source(none) 关闭自动探测），避免把 node_modules 等无关文件算进产物。
 * 2. JS 中通过字符串拼接产生的动态类名，在 JS 里都是字面量，因此扫描 assets/js 即可被正确收录。
 * 3. 改动模板/脚本里的类名后，需重新执行 `npm run build:css`。
 */
@import "tailwindcss" source(none);
@plugin "daisyui";

/* 主题模板与 PHP 模块 */
@source "../../../*.php";
@source "../../../template-parts/*.php";
@source "../../../inc/*.php";

/* 前端脚本（含动态类名字面量） */
@source "../../js/*.js";
```

### `@source` 扫描范围说明（关键，写错会漏样式）

| 行 | 相对 `main.css`（`assets/css/src/`）的实际范围 | 为什么需要 |
| --- | --- | --- |
| `@source "../../../*.php"` | 主题**根目录**下的 PHP（`header.php`、`single.php`、`404.php`、`searchform.php` 等） | 模板类名的主要来源；`*.php` **不递归**，所以子目录必须另列 |
| `@source "../../../template-parts/*.php"` | `template-parts/` 片段 | 列表卡片、侧栏模块的类名大多在这里 |
| `@source "../../../inc/*.php"` | `inc/` 模块（评论回调、SEO 输出、表单字段等会拼 HTML 类名） | 评论头像、徽标、表单控件的类名来源 |
| `@source "../../js/*.js"` | `assets/js/` 前端脚本 | JS 里通过字符串字面量拼出的类名（如 `hidden`、`alert-success`）也需被收录 |

规则：

1. `source(none)` **必须保留**：关闭自动探测，否则 Tailwind 会把 `node_modules/`、`vendor/` 一起扫描，产物可能膨胀到数 MB 且混入无关类。
2. **新增目录要补 `@source`**：以后加了 `woocommerce/`、`blocks/`、`inc/admin/` 等含类名的目录，必须追加一行 `@source`，否则该目录的类名不会进产物。
3. 类名必须是**字面量**：动态拼接（如 `'badge-' + type`）时，被拼接出来的结果不会进产物；让拼接结果在 JS 中以字面量出现（`status-warning`、`alert-success`），或写完整的 `badge-warning badge-success` 备用字面量。

---

## 三、首次安装与构建命令

```bash
# 1) 首次：安装构建依赖（只需一次；node 20 / npm 10 实测可用）
cd wp-content/themes/your-theme
npm install

# 2) 构建：产出静态样式（每次改类名后都要执行）
npm run build:css

# 3) 开发期监听：改文件即自动重建（长期开着，交付前记得跑一次 build:css 出压缩产物）
npm run watch:css
```

- `build:css` 带 `--minify`，产出压缩后的 `assets/css/tailwind.css`；`watch:css` 不带 `--minify`，产物更大，**只用于开发**，不要拿去上线。
- 构建不需要 WordPress 运行，在主题目录里独立执行即可。

### 输入不依赖 `wp_enqueue`，输出必须入队

`functions.php` 里按「文件存在才入队」的方式注册产物，并用 `filemtime()` 做版本号（改动后 URL 自动变化，避免读到旧缓存）：

```php
function xy_mbx_enqueue_assets() {
	$tailwind_relative_path = 'assets/css/tailwind.css';
	$tailwind_absolute_path = XY_MBX_THEME_DIR . $tailwind_relative_path;

	// 产物不存在时不注册，避免产生 404 请求。
	if ( file_exists( $tailwind_absolute_path ) ) {
		wp_enqueue_style( 'xy-mbx-tailwind', XY_MBX_THEME_URI . $tailwind_relative_path, array(), (string) filemtime( $tailwind_absolute_path ) );
	}
}
add_action( 'wp_enqueue_scripts', 'xy_mbx_enqueue_assets' );
```

---

## 四、铁律：改类名必须重新构建

> **任何新增或修改的 Tailwind / daisyUI 类名，都必须在主题目录重新执行 `npm run build:css`，否则该样式不会生效。**

容易漏的地方（都是「类名变了但没构建」的典型）：

- 在 `single.php` 里加了一个 `mt-6` 或 `lg:col-start-2`；
- 在 `inc/comments.php` 里改了表单类名（`input`、`textarea`、`btn btn-primary btn-sm`）；
- 在 `template-parts/related-posts.php` 里加了新模块的类名；
- 在 `assets/js/mailbox.js` 里加了新的状态类名；
- 交付/上线前只复制了 PHP 文件，忘了同步 `assets/css/tailwind.css`。

`inc/comments.php` 与 `template-parts/related-posts.php` 的文件头注释已把这条写成显式提醒：

```php
 * 3. 样式全部使用 daisyUI / Tailwind 类名，新增或修改类名后需执行 `npm run build:css`。
```

---

## 五、产物自检方法

### 1) 体积区间

| 情况 | 体积 | 判断 |
| --- | --- | --- |
| 正常（一个中小主题，daisyUI 全量组件 + 已用工具类） | 约 **66–71 KB**（实测 73,087 字节 ≈ 71 KB） | ✅ |
| 明显偏小（< 30 KB） | 很可能只扫到了部分文件，或 daisyUI 插件没生效 | 检查 `@plugin "daisyui";` 与 `@source` |
| 明显偏大（> 500 KB，乃至数 MB） | 很可能 `source(none)` 被删、扫进了 `node_modules` | 检查 `@import "tailwindcss" source(none);` |

PowerShell 一行查看（Windows）：

```powershell
$f = "wp-content/themes/your-theme/assets/css/tailwind.css"
(Get-Item $f).Length / 1KB
```

### 2) 关键类名是否进了产物

先确认页面/模板里用到的关键类名能在产物中找到（`lg\:sticky` 这类带冒号的类名在 CSS 里是转义的 `.lg\:sticky`，用**字面子串**匹配）：

```powershell
$f = "wp-content/themes/your-theme/assets/css/tailwind.css"
$t = Get-Content -Raw $f
foreach ($c in @('lg\:sticky', 'lg\:row-span-2', 'lg\:grid-cols-', 'rounded-box', 'breadcrumbs', 'line-clamp-2', 'avatar-placeholder', 'join-item', '.card-title', '--radius-box')) {
	"$c => " + $t.Contains($c)
}
```

全部为 `True` 才算通过。给定关键类名（`lg:sticky` / `lg:row-span-2`）返回 `False`，说明该模板类名没被扫到或忘记构建。

跨平台等价的 grep（字面匹配，注意转义）：

```bash
grep -F -c 'lg\:sticky'     assets/css/tailwind.css
grep -F -c 'lg\:row-span-2' assets/css/tailwind.css
grep -F -c 'rounded-box'    assets/css/tailwind.css
```

> 产物是 `--minify` 压缩后的极少数行文件（实测仅 2 行），不要用「匹配到几行」判断，用「是否存在字面子串」判断。

### 3) 编译产物形态确认

`lg:max-h-[calc(100vh-3rem)]` 在产物中会被转义为：

```css
.lg\:max-h-\[calc\(100vh-3rem\)\]{max-height:calc(100vh - 3rem)}
```

所以自检时匹配 `lg\:max-h` 这种**前缀**即可，不要写完整的方括号/圆括号表达式（`\`、`(`、`)` 都被转义，容易匹配失败）。

---

## 六、排错清单

| 现象 | 排查方向与处理 |
| --- | --- |
| 某个类名没生效（其余样式正常） | ① 是否漏了 `npm run build:css`（最常见）；② 该类名是否写在未被 `@source` 覆盖的文件/目录里 → 补 `@source` 后重建；③ 类名是否由 JS 动态拼接而非字面量 → 改为字面量；④ 浏览器或 CDN 缓存 → 产物用 `filemtime()` 版本号，正常不会读旧缓存，CDN 需清缓存 |
| 整站完全没有样式 | `assets/css/tailwind.css` 缺失（此时主题**故意不注册**该样式，避免 404）→ 确认文件存在；缺失就重新 `npm run build:css` 并上传产物 |
| 只有一部分页面样式错乱 | 该页面模板的类名没进产物（在新增目录里 / 后缀不符合 `@source` 模式）→ 补 `@source` 重建 |
| 构建命令报 `tailwindcss: command not found` | 未执行 `npm install`，或 Node 版本过低（实测 node 20 / npm 10） |
| 产物突然涨到几 MB | `source(none)` 被删掉，扫进了 `node_modules/` → 恢复 `@import "tailwindcss" source(none);` |
| 站点装了 WindPress，两边都编译 | 见下节：产物存在时由主题主动关闭其前台运行时编译 |
| 线上样式是旧版 | 上传了新 PHP 但没上传新的 `assets/css/tailwind.css`；两者必须同批更新 |

### WindPress 存在时的兜底策略

主题在 `functions.php` 里挂一个过滤器：**产物存在就关闭 WindPress 的前台运行时编译**（避免首屏样式延迟与 esm.sh 依赖）；**产物缺失时不拦截**，让 WindPress 运行时兜底，从而不会出现「完全无样式」：

```php
function xy_mbx_disable_windpress_runtime() {
	return file_exists( XY_MBX_THEME_DIR . 'assets/css/tailwind.css' );
}
add_filter( 'f!windpress/core/runtime:is_prevent_load', 'xy_mbx_disable_windpress_runtime' );
```

该过滤器只影响前台，WindPress 后台与编辑器功能不受影响。

---

## 七、什么时候才允许写自定义 CSS

自定义样式统一写在 `assets/css/theme.css`（手写、**不需要构建**），且**只允许写工具类与组件类无法表达的规则**。允许的类别（参考实现里出现的全部情况）：

| 类别 | 示例选择器 | 为什么不能用工具类 |
| --- | --- | --- |
| 全局字体栈 | `body { font-family: system-ui, …, "Microsoft YaHei", sans-serif; }` | Tailwind 默认字体栈不含中文字体 |
| 富文本正文排版 | `.xy-entry h2 / p / ul / blockquote / pre / code / img / table / figcaption` | 文章正文由编辑器输出，无法逐个挂类名；项目未引入 `@tailwindcss/typography`，因此手写必要规则，颜色一律用 daisyUI 语义变量（`var(--color-base-content)` 等） |
| iframe 渲染隔离 | `.xy-mail-frame { display:block; width:100%; height:min(70vh,560px); border:0; }` | 固定高度与去边框属第三方内容的兜底 |
| 分页外观 | `.pagination .nav-links`、`.page-numbers`、`.page-numbers.current`、`.page-numbers.dots` | `the_posts_pagination()` 输出的是 WordPress 固定结构的 `.page-numbers`，无法挂 daisyUI 组件类 |
| 防撑破兜底 | `.card-title a, .card-body p { overflow-wrap: anywhere; }` | 超长单词 / 无空格 URL 需要全局兜底 |
| 屏幕阅读器工具类补齐 | `.pagination .screen-reader-text { … }` | 分页标题仅给屏幕阅读器使用，需补齐隐藏规则 |

不允许的做法：

1. 用自定义 CSS **重复实现** daisyUI 已提供的组件外观（按钮、卡片、徽标、输入框等）——直接用 `btn` / `card` / `badge` / `input` 等类名。
2. 为了方便，把本该用工具类表达的间距、颜色、圆角写进 `theme.css`。
3. 在模板里写 `<style>` 内联块（样式无法被统一管理与版本化）。
