# {{主题名}}· 打包与上传命令

> **职责**：把开发中的主题目录打成**可直接在后台上传安装的 zip**，并保证 zip 里不含 `node_modules`、工作物料与临时目录。
> **来源**：本技能包自带的通用打包流程模板（Windows PowerShell），按下面的 TODO 改成你的路径即可。
> **落地位置**：新主题根目录 `打包命令.md`（只作说明，不参与运行）。
> **环境**：Windows PowerShell 5.1 / PowerShell 7 均可（`robocopy` 与 `Compress-Archive` 均为系统自带）。
>
> **复制后必须改的地方**：
> TODO 1. 全文 `mytheme` / `MYTHEME_` 替换为你的主题短名；脚本里 `$ThemeSlug` / `$ThemeParent` / `$Dist` / `$Version` 改成你的路径与版本。
> TODO 2. `$ProdConfig` 里填生产值（或改走 wp-config.php 方案，直接删掉「第 2 步」）。
> TODO 3. 「二、体积参考」的条目数量按本项目实际情况填写；「常见坑」按本项目补充。
> TODO 4. 本文档**不得写入真实密钥**——示例里只出现占位值。

---

## 一、完整打包脚本（复制整段执行）

```powershell
# ============================================================
# 主题打包：暂存复制 → 写入生产配置 → 压缩 → 校验
# ============================================================

# ---- 0. 变量 ----
$ThemeSlug = 'mytheme'                                                  # 主题目录名（与 style.css 的 Text Domain 一致）
# TODO: 改成你的站点主题目录（本节示例用占位路径）
$ThemeParent = 'C:\path\to\wordpress\wp-content\themes'                 # 主题目录的上一级（打包源所在目录）
$Src       = Join-Path $ThemeParent $ThemeSlug                          # 开发中的主题目录（打包源，不会被改动）
$Dist      = 'C:\path\to\dist'                                          # 产物输出目录
$Version   = '1.0.0'
$StageRoot = Join-Path $env:TEMP 'wp-theme-pkg'                         # 暂存根目录（每次重建）
$Stage     = Join-Path $StageRoot $ThemeSlug                            # 暂存的主题目录（zip 顶层就是它）
$Zip       = Join-Path $Dist "$ThemeSlug-v$Version.zip"

# 生产配置：打包时替换进「暂存副本」的 inc/config.php，源码保持占位值不动
$ProdConfig = @{
    'MYTHEME_API_BASE'   = 'https://api.example.com'
    'MYTHEME_APP_ID'     = 'YOUR_APP_ID'
    'MYTHEME_APP_SECRET' = 'YOUR_APP_SECRET'
}

# ---- 1. 暂存：只复制要分发的文件 ----
if (Test-Path $StageRoot) { Remove-Item $StageRoot -Recurse -Force }
New-Item -ItemType Directory -Path $StageRoot -Force | Out-Null
if (-not (Test-Path $Dist)) { New-Item -ItemType Directory -Path $Dist -Force | Out-Null }

# /XD 排除目录名（任意层级命中即排除）；/XF 排除文件名
robocopy $Src $Stage /E `
    /XD node_modules .git .svn .vscode .idea dist _work work tmp temp cache logs `
    /XF *.zip *.log *.map .DS_Store Thumbs.db desktop.ini `
    /NFL /NDL /NJH /NJS /NP | Out-Null

# robocopy 退出码 0–7 都算成功（1 表示有文件被复制），>= 8 才是真失败
if ($LASTEXITCODE -ge 8) { throw "robocopy 复制失败，退出码 $LASTEXITCODE" }

# 预编译样式必须先构建，否则打包出来前台没样式
if (-not (Test-Path (Join-Path $Stage 'assets\css\tailwind.css'))) {
    throw '缺少 assets/css/tailwind.css —— 先在开发机执行 npm install && npm run build:css'
}

# ---- 2. 把生产配置写进「暂存副本」（字符串替换，不改源码） ----
$StagedConfig = Join-Path $Stage 'inc\config.php'
if (-not (Test-Path $StagedConfig)) { throw "暂存目录里没有 inc\config.php：$StagedConfig" }

$text = Get-Content -Path $StagedConfig -Raw -Encoding UTF8
foreach ($key in $ProdConfig.Keys) {
    # 只替换 define( 'KEY', '旧值' ) 的第二个参数，注释与其它代码原样保留
    $pattern = "(define\(\s*'$key'\s*,\s*)'[^']*'"
    $text    = [regex]::Replace($text, $pattern, "`$1'$($ProdConfig[$key])'")
}

# 校验替换真的生效：静默失败会让生产站点去调占位地址
foreach ($key in $ProdConfig.Keys) {
    if ($text -notmatch "define\(\s*'$key'\s*,\s*'$([regex]::Escape($ProdConfig[$key]))'\s*\)") {
        throw "配置替换失败：$key（确认 inc/config.php 里是 define( '$key', '...' ) 写法）"
    }
}

# 必须写成「无 BOM 的 UTF-8」：带 BOM 的 PHP 文件会在任何输出之前吐出三个字节，
# 造成 headers already sent 之类的连锁问题
[System.IO.File]::WriteAllText($StagedConfig, $text, (New-Object System.Text.UTF8Encoding($false)))

# ---- 3. 压缩（-Path 传暂存的主题目录本身，zip 顶层就是单一主题目录） ----
Compress-Archive -Path $Stage -DestinationPath $Zip -CompressionLevel Optimal -Force

# ---- 4. 校验 zip ----
Add-Type -AssemblyName System.IO.Compression.FileSystem
$archive = [System.IO.Compression.ZipFile]::OpenRead($Zip)
try {
    # 条目的分隔符在部分环境是反斜杠，统一成正斜杠再判断
    $names = ($archive.Entries | ForEach-Object { $_.FullName }) -replace '\\', '/'

    # 外层必须用 @() 包住：只有 1 个唯一值时，管道会把结果折叠成标量字符串，
    # 那样 $roots.Count 仍是 1，但 $roots[0] 会退化成首字符（mytheme -> m）。
    # 比较基准是「不带尾斜杠」的目录名：($_.FullName -split '/')[0] 取到的是首段本身。
    $roots = @($names | ForEach-Object { ($_ -split '/')[0] } | Select-Object -Unique)
    if ($roots.Count -ne 1)           { throw "zip 顶层不是单一目录，实际为：$($roots -join ', ')" }
    if ($roots[0] -ne $ThemeSlug)     { throw "zip 顶层目录名不是 $ThemeSlug，实际为 $($roots[0])" }
    if ($names -match '(^|/)node_modules/')                       { throw 'zip 内含 node_modules' }
    if ($names -match '(^|/)(_work|work|tmp|temp|dist|logs)/')    { throw 'zip 内含工作物料 / 临时目录' }
    if ($names -notcontains "$ThemeSlug/assets/css/tailwind.css") { throw '缺少预编译样式 assets/css/tailwind.css' }
    if ($names -notcontains "$ThemeSlug/style.css")               { throw '缺少 style.css（主题头），WordPress 无法识别主题' }

    # 抽查成品里的配置：不能残留占位值
    # 注意：条目名的分隔符在 Windows PowerShell 5.1 里是反斜杠，而 ZipFile.GetEntry() 只按传入的
    # 字面值匹配，传 "$ThemeSlug/inc/config.php" 会取到 $null（随后调用 .Open() 直接报错），
    # 所以按上面归一化好的名字自己找条目。
    $entry = $archive.Entries | Where-Object { ($_.FullName -replace '\\', '/') -eq "$ThemeSlug/inc/config.php" } | Select-Object -First 1
    if ($null -eq $entry) { throw "zip 内缺少 $ThemeSlug/inc/config.php" }
    $reader = New-Object System.IO.StreamReader($entry.Open())
    $packed = $reader.ReadToEnd()
    $reader.Close()
    if ($packed -match 'YOUR_APP_ID|YOUR_APP_SECRET|api\.example\.com') {
        throw 'inc/config.php 仍残留占位值，检查第 2 步的替换'
    }

    Write-Host "校验通过：共 $($names.Count) 个条目" -ForegroundColor Green
} finally {
    $archive.Dispose()
}

Get-Item $Zip | Select-Object Name, @{n = 'Size(KB)'; e = { [math]::Round($_.Length / 1KB, 1) } }

# ---- 5. 清理暂存（可选） ----
Remove-Item $StageRoot -Recurse -Force
```

### 关键点说明

| 做法 | 为什么 |
| --- | --- |
| 先 `robocopy` 到暂存目录，再压缩暂存目录 | 打包绝不改动开发目录；生产配置只写进副本，源码里始终是占位值 |
| `/XD node_modules _work work tmp dist logs` | `node_modules` 只服务构建，打进 zip 会让体积涨到几十 MB 且毫无用处 |
| `Compress-Archive -Path $Stage` | 传**主题目录本身**，zip 顶层才是单一主题目录；写成 `$Stage\*` 会让 zip 顶层变成一堆散文件，后台安装报「主题缺少 style.css」 |
| 字符串替换而非改源码 | 源码不用为打包来回改密钥，也就不会误提交；替换后必须回读校验，避免正则不匹配却当作成功 |
| `UTF8Encoding($false)` | 避免写入 BOM；带 BOM 的 PHP 文件会在输出前吐字节，导致 `headers already sent` |
| zip 内 `assets/css/tailwind.css` 必须存在 | 该文件是预编译产物，缺了前台就没样式（主题故意不注册缺失文件，以免 404） |

> 备用方案：若你的凭证本来就写在 `wp-config.php`（推荐做法），可以整段删掉上面的第 2 步，
> zip 里保留占位值也无所谓——运行时会优先使用 `wp-config.php` 中的定义。

---

## 二、体积参考

| 对象 | 正常范围 | 异常判断 |
| --- | --- | --- |
| `assets/css/tailwind.css` | 约 **66–71 KB** | < 30 KB：`@source` 漏扫或 daisyUI 插件没生效；> 500 KB：`source(none)` 被删，扫进了 `node_modules` |
| 完整主题 zip | 约 **200 KB – 1 MB** | > 5 MB：几乎一定混进了 `node_modules` / 备份目录 / 媒体文件，回看第一节的排除项 |
| 条目数 | {{按实际填写，例如 60–120 个}} | 明显偏多说明混入了非主题文件 |

打包后想看某个条目的内容（例如确认配置已替换）：

```powershell
Add-Type -AssemblyName System.IO.Compression.FileSystem
$zip = [System.IO.Compression.ZipFile]::OpenRead('C:\path\to\dist\mytheme-v1.0.0.zip')
($zip.Entries | Where-Object { $_.FullName -match 'inc[\\/]config\.php' }).FullName
$zip.Dispose()
```

---

## 三、上传覆盖安装（升级）

1. **先备份**：把线上 `wp-content/themes/{{主题目录名}}/` 整目录复制一份留底，并导出一次数据库。
2. **后台上传（推荐）**：后台 →「外观 → 主题 → 安装主题 → 上传主题」→ 选择 zip →
   安装时提示「目标文件夹已存在」→ 选择「**替换现有版本**」→ 完成后确认主题仍处于启用状态。
3. **或 SFTP / 面板解压**：把 zip 上传到 `wp-content/themes/`，就地解压覆盖（宝塔可在「文件 → 解压」里操作）。
   注意解压后目录层级必须是 `wp-content/themes/{{主题目录名}}/style.css`，不要多套一层。
4. **验证**：
   - 前台强制刷新（Ctrl+F5），页面样式完整、无横向滚动条；
   - 查看页面源代码里 `assets/css/tailwind.css` 的 URL，版本号（`?ver=`）已随 `filemtime()` 变化，说明新文件已生效；
   - 打开一篇文章，确认正文、侧栏模块、评论区都正常；
   - 后台「外观 → 主题」能看到预览图。
5. **凭证检查**：凭证若写在 `inc/config.php` 且被本次覆盖，请重新填入；**建议改成写在 `wp-config.php`**，以后升级就不用管。
6. **回滚**：把第 1 步备份的目录恢复回去即可；数据库层面主题只写自身数据，不会锁死站点。

### 常见坑

| 现象 | 原因与处理 |
| --- | --- |
| 后台提示「主题缺少 style.css」 | zip 顶层不是单一主题目录（用了 `$Stage\*` 打包），或压缩时多套了一层目录 |
| 上传后前台没样式 | zip 里没有 `assets/css/tailwind.css`，或改了类名但忘了 `npm run build:css` |
| 上传后接口全部报错 | 生产配置没替换成功（占位值残留），或替换写入时带了 BOM |
| zip 体积异常大 | 排除项没生效，回看第一节的 `/XD` `/XF` 参数 |
| 上传失败 / 超时 | 压缩等级改 `Optimal`、清掉无用素材；或改用 SFTP 解压覆盖 |

---

## 四、自测记录（模板作者实测）

做法：造一个假主题目录 `mytheme`（`style.css`、`assets/css/tailwind.css`、`assets/css/src/main.css`、`inc/config.php`（占位值）、`package.json`，另放一个 `node_modules/` 用于验证排除项），把脚本的 `$ThemeParent` / `$Dist` 指向临时目录、`$ProdConfig` 填非占位测试值，然后**整段执行第一节的脚本原文**（Windows PowerShell 5.1 与 PowerShell 7 各跑一遍）。

**成功场景（两个版本均通过）**

```
校验通过：共 6 个条目
Name               Size(KB)
----               --------
mytheme-v1.0.0.zip     1.20

zip 内条目总数      : 6
node_modules 条目数 : 0
顶层目录            : mytheme
全部条目名          : mytheme/assets/ | mytheme/package.json | mytheme/style.css
                    | mytheme/assets/css/tailwind.css | mytheme/assets/css/src/main.css
                    | mytheme/inc/config.php
```

**故意失败场景（确认断言真的会拦住，并打印真实值）**

```
# 1) 把 zip 顶层目录改成 renamed-theme（$ThemeSlug 仍为 mytheme）
zip 顶层目录名不是 mytheme，实际为 renamed-theme

# 2) zip 里多塞一个顶层文件 stray.txt
zip 顶层不是单一目录，实际为：mytheme, stray.txt
```

**本次修复点的对照实测**

```
# 顶层目录断言：旧的 $roots[0] 写法在「只有一个唯一值」时必然误判
旧写法 类型=String    Count=1 roots[0]='m'        与基准比较 -> True   # 必然抛错「实际为 m」
新写法 类型=Object[]  Count=1 roots[0]='mytheme'  与基准比较 -> False  # 正常通过

# Windows PowerShell 5.1 的 Compress-Archive 生成的条目名是反斜杠（PowerShell 7 是正斜杠）
GetEntry('mytheme/inc/config.php') 取到条目: False   # 旧写法在此环境下报「null 值上不能调用方法」
归一化后查找 inc/config.php 取到条目      : True    # 修复后正常
