diff --git a/.gitignore b/.gitignore
index cb77605..56d7dc2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,5 @@
public
themes/hugo-book/**
resources/_gen
+*.log
+assets/items/*.png
\ No newline at end of file
diff --git a/assets/items/README.md b/assets/items/README.md
new file mode 100644
index 0000000..ec2f838
--- /dev/null
+++ b/assets/items/README.md
@@ -0,0 +1,41 @@
+# 物品贴图库
+
+本目录存放 `crafting` 合成组件使用的物品贴图(PNG)。
+
+## 命名规则
+
+一个物品对应一个文件:`<名称>.png`,文件名(不含 `.png`)就是引用时的名字。
+
+例如放入 `diamond.png` 后,在合成组件中写 `a1="diamond"` 即可显示钻石。
+
+> 推荐使用 16×16(或 32×32)的 Minecraft 像素风贴图,`image-rendering: pixelated` 会保证缩放后依然清晰锐利。
+
+## 自动同步(推荐)
+
+构建 / 开发时由 `scripts/sync-items.ps1` 自动维护本目录:
+
+- sfcraft 自定义贴图(`amethyst_cauldron_blank`、`exp_totem`、`lunch_box`、`pearl_token` 等)
+ 从 [sfcraft 仓库](https://github.com/saltedfishclub/sfcraft/tree/rev/26.2/src/main/resources/assets/sfcraft/textures/item)
+ 自动下载,缺失或远端变更时更新;
+- 原版物品(`diamond`、`stick` 等)在本地缺失时从 Minecraft wiki 自动获取;
+- 手动放入的贴图不会被覆盖;找不到的贴图会报错并列出物品 id,阻止构建 / 开发。
+
+请使用 `scripts\build.ps1` / `scripts\dev.ps1` 而非直接运行 `hugo`,详见 `scripts/README.md`。
+
+## 组件用法
+
+```markdown
+{{< crafting
+ a1="diamond" a2="diamond" a3="diamond"
+ b1="" b2="stick" b3=""
+ c1="" c2="stick" c3=""
+ out="diamond_pickaxe"
+ caption="钻石镐(可选)"
+>}}
+```
+
+- `a1`~`c3`:3×3 合成格的九个格子,`out`:输出格;
+- 值 = 贴图文件名(不含 `.png`),**留空 / 省略 = 空格子**;
+- 需要显示堆叠数量时写 `名称:数量`,例如 `b2="stick:2"`;
+- `caption` 可选,显示在组件下方居中说明文字;
+- 未运行自动同步且贴图缺失时,格子内会显示一个 `?` 占位,方便发现写错的名字。
diff --git a/assets/table.png b/assets/table.png
new file mode 100644
index 0000000..aba9000
Binary files /dev/null and b/assets/table.png differ
diff --git a/layouts/_partials/crafting-slot.html b/layouts/_partials/crafting-slot.html
new file mode 100644
index 0000000..37e6b8b
--- /dev/null
+++ b/layouts/_partials/crafting-slot.html
@@ -0,0 +1,29 @@
+{{- $val := trim (.ctx.Get .key) " " -}}
+{{- $item := "" -}}
+{{- $count := 0 -}}
+{{- if $val -}}
+ {{- $parts := split $val ":" -}}
+ {{- $item = index $parts 0 -}}
+ {{- if gt (len $parts) 1 -}}
+ {{- $count = int (index $parts 1) -}}
+ {{- end -}}
+{{- end -}}
+
+{{- $img := "" -}}
+{{- if $item -}}
+ {{- $img = resources.Get (printf "items/%s.png" $item) -}}
+{{- end -}}
+
+
+ {{- if $img -}}
+

+ {{- if gt $count 1 -}}
+
{{ $count }}
+ {{- end -}}
+ {{- else if $item -}}
+
?
+ {{- end -}}
+
diff --git a/layouts/_shortcodes/crafting.html b/layouts/_shortcodes/crafting.html
new file mode 100644
index 0000000..687539f
--- /dev/null
+++ b/layouts/_shortcodes/crafting.html
@@ -0,0 +1,124 @@
+{{- /* SFCraft 合成配方组件
+ - 调用: 页面内写 shortcode, 名为 crafting
+ - a1..c3 为 3x3 合成格, out 为输出格
+ - 值为 assets/items/ 下贴图的文件名(不含 .png), 留空表示空格子
+ - 名称后可加 ":数量" 显示堆叠数, 例如 a1="stick:2"
+ - 引用的贴图不存在时, 格子内显示 ? 占位提示
+ - caption 参数可选, 显示在组件下方
+*/ -}}
+
+{{- $table := resources.Get "table.png" -}}
+{{- $slots := slice
+ (dict "key" "a1" "cls" "grid" "x" "13.75%" "y" "20%")
+ (dict "key" "a2" "cls" "grid" "x" "25%" "y" "20%")
+ (dict "key" "a3" "cls" "grid" "x" "36.25%" "y" "20%")
+ (dict "key" "b1" "cls" "grid" "x" "13.75%" "y" "42.5%")
+ (dict "key" "b2" "cls" "grid" "x" "25%" "y" "42.5%")
+ (dict "key" "b3" "cls" "grid" "x" "36.25%" "y" "42.5%")
+ (dict "key" "c1" "cls" "grid" "x" "13.75%" "y" "65%")
+ (dict "key" "c2" "cls" "grid" "x" "25%" "y" "65%")
+ (dict "key" "c3" "cls" "grid" "x" "36.25%" "y" "65%")
+ (dict "key" "out" "cls" "out" "x" "70%" "y" "37.5%")
+-}}
+
+{{- $cssOnce := .Page.Store.Get "sfc_crafting_css" -}}
+{{- if not $cssOnce -}}
+ {{- .Page.Store.Set "sfc_crafting_css" true -}}
+
+{{- end -}}
+
+
+ {{- range $slots -}}
+ {{- partial "crafting-slot.html"
+ (dict
+ "ctx" $
+ "key" .key
+ "cls" .cls
+ "x" .x
+ "y" .y
+ ) -}}
+ {{- end -}}
+ {{- with .Get "caption" -}}
+ {{ . }}
+ {{- end -}}
+
diff --git a/scripts/README.md b/scripts/README.md
new file mode 100644
index 0000000..e2c40d9
--- /dev/null
+++ b/scripts/README.md
@@ -0,0 +1,40 @@
+# 构建 / 开发脚本
+
+## 用途
+
+`sync-items.ps1` 会在构建和开发前自动保证物品贴图齐全:
+
+1. 下载 [sfcraft 仓库](https://github.com/saltedfishclub/sfcraft/tree/rev/26.2/src/main/resources/assets/sfcraft/textures/item) 的
+ 全部自定义贴图到 `assets/items/`;
+2. 扫描 `content/` 中所有 `crafting` 组件用到的物品 id;
+3. 本地缺失的原版物品从 Minecraft wiki 自动获取(自动挑选最新版本渲染);
+4. 有任何物品无法找到时, 打印缺失清单并以非零码退出, 阻断构建 / 开发启动。
+
+## 使用
+
+```powershell
+.\scripts\build.ps1 # 同步贴图 + hugo 构建
+.\scripts\dev.ps1 # 同步贴图 + hugo server
+.\scripts\dev.ps1 -NoSync # 仅启动开发服务器, 不重新同步
+```
+
+若 PowerShell 执行策略阻止脚本, 请用:
+
+```powershell
+powershell -ExecutionPolicy Bypass -File scripts\dev.ps1
+```
+
+脚本通过 `$env:HUGO` 或 PATH 查找 hugo, 找不到时也会探测 winget 安装位置。
+
+## 规则细节
+
+- sfcraft 自定义贴图: 每次构建比对远端文件大小, 缺失或变化时更新;
+- 原版贴图: 只在本地缺失时下载, **不会覆盖**手动放入 `assets/items/` 的贴图;
+- 物品 id 即 `assets/items/` 下的文件名(不含 `.png`);
+- wiki 命名规则: `<英文名>_JE<版本>[_BE<版本>].png`, 特殊命名(如 `TNT`、`Flint_and_Steel`)
+ 由 `sync-items.ps1` 中的变体生成与别名表处理。
+
+## 网络要求
+
+需要能访问 `api.github.com`、`raw.githubusercontent.com` 与 `zh.minecraft.wiki`。
+若 CI 或离线环境不允许联网, 建议把 `assets/items/` 下的贴图提交进仓库。
diff --git a/scripts/build.ps1 b/scripts/build.ps1
new file mode 100644
index 0000000..60b06b5
--- /dev/null
+++ b/scripts/build.ps1
@@ -0,0 +1,35 @@
+<#
+ SFCraft 文档构建入口: 先同步物品贴图, 再执行 hugo 构建。
+ 贴图同步失败(有物品找不到)时终止构建。
+ 用法: powershell -ExecutionPolicy Bypass -File scripts\build.ps1 [hugo 参数...]
+#>
+[CmdletBinding()]
+param(
+ [Parameter(ValueFromRemainingArguments = $true)]
+ [string[]]$HugoArgs
+)
+
+$ErrorActionPreference = 'Stop'
+$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
+
+function Get-HugoPath {
+ if ($env:HUGO) { return $env:HUGO }
+ $cmd = Get-Command hugo -ErrorAction SilentlyContinue
+ if ($cmd) { return $cmd.Source }
+ $p = Get-ChildItem -Path (Join-Path $env:LOCALAPPDATA 'Microsoft\WinGet\Packages') -Recurse -Filter hugo.exe -ErrorAction SilentlyContinue |
+ Sort-Object LastWriteTime -Descending | Select-Object -First 1
+ if ($p) { return $p.FullName }
+ throw '未找到 Hugo。请安装 Hugo, 或将 hugo 可执行文件路径写入环境变量 HUGO。'
+}
+
+Write-Host "`n[1/2] 同步物品贴图" -ForegroundColor Cyan
+& (Join-Path $scriptDir 'sync-items.ps1')
+if ($LASTEXITCODE -ne 0) {
+ Write-Host "[构建终止] 物品贴图同步失败, 未执行 hugo 构建。" -ForegroundColor Red
+ exit $LASTEXITCODE
+}
+
+Write-Host "`n[2/2] hugo 构建" -ForegroundColor Cyan
+$hugo = Get-HugoPath
+& $hugo @HugoArgs
+exit $LASTEXITCODE
diff --git a/scripts/dev.ps1 b/scripts/dev.ps1
new file mode 100644
index 0000000..6e0d9ae
--- /dev/null
+++ b/scripts/dev.ps1
@@ -0,0 +1,42 @@
+<#
+ SFCraft 文档开发服务器入口: 先同步物品贴图, 再启动 hugo server。
+ 贴图同步失败(有物品找不到)时终止启动。
+ 用法: powershell -ExecutionPolicy Bypass -File scripts\dev.ps1 [-NoSync] [hugo server 参数...]
+#>
+[CmdletBinding()]
+param(
+ [switch]$NoSync,
+ [Parameter(ValueFromRemainingArguments = $true)]
+ [string[]]$HugoArgs
+)
+
+$ErrorActionPreference = 'Stop'
+$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
+
+function Get-HugoPath {
+ if ($env:HUGO) { return $env:HUGO }
+ $cmd = Get-Command hugo -ErrorAction SilentlyContinue
+ if ($cmd) { return $cmd.Source }
+ $p = Get-ChildItem -Path (Join-Path $env:LOCALAPPDATA 'Microsoft\WinGet\Packages') -Recurse -Filter hugo.exe -ErrorAction SilentlyContinue |
+ Sort-Object LastWriteTime -Descending | Select-Object -First 1
+ if ($p) { return $p.FullName }
+ throw '未找到 Hugo。请安装 Hugo, 或将 hugo 可执行文件路径写入环境变量 HUGO。'
+}
+
+if (-not $NoSync) {
+ Write-Host "`n[1/2] 同步物品贴图" -ForegroundColor Cyan
+ & (Join-Path $scriptDir 'sync-items.ps1')
+ if ($LASTEXITCODE -ne 0) {
+ Write-Host "[启动终止] 物品贴图同步失败, 未启动 hugo server。" -ForegroundColor Red
+ exit $LASTEXITCODE
+ }
+} else {
+ Write-Host "[跳过] 已指定 -NoSync, 不执行贴图同步" -ForegroundColor Yellow
+}
+
+Write-Host "`n[2/2] 启动 hugo server" -ForegroundColor Cyan
+$hugo = Get-HugoPath
+$serverArgs = @('server')
+if ($HugoArgs.Count -gt 0) { $serverArgs += $HugoArgs }
+& $hugo @serverArgs
+exit $LASTEXITCODE
diff --git a/scripts/sync-items.ps1 b/scripts/sync-items.ps1
new file mode 100644
index 0000000..45c506d
--- /dev/null
+++ b/scripts/sync-items.ps1
@@ -0,0 +1,233 @@
+<#
+ SFCraft 文档 · 物品贴图自动同步
+
+ 流程:
+ 1. 将 sfcraft 仓库 textures/item 目录下所有贴图同步到 assets/items
+ 2. 扫描 content 中所有 crafting 组件, 收集用到的物品 id
+ 3. 对本地缺失的 id, 从 Minecraft wiki 自动获取(按命名规则挑选最新版本)
+ 4. 仍有无法获取的 id 时, 列出并退出(返回码 1), 阻断 build / dev
+
+ 用法:
+ powershell -ExecutionPolicy Bypass -File scripts\sync-items.ps1 [-Force]
+
+ 说明:
+ - sfcraft 自定义贴图每次与远端比对大小, 缺失或变更时更新
+ - 原版贴图只在本地缺失时下载, 不会覆盖手动放入 assets/items 的贴图
+ - 需要联网访问 api.github.com / zh.minecraft.wiki
+#>
+[CmdletBinding()]
+param(
+ [switch]$Force
+)
+
+$ErrorActionPreference = 'Stop'
+
+$repoRoot = Split-Path -Parent $PSScriptRoot
+$itemsDir = Join-Path $repoRoot 'assets\items'
+$contentDir = Join-Path $repoRoot 'content'
+$ua = 'sfcraft-docs-sync/1.0 (https://github.com/saltedfishclub/sfcraft-docs)'
+
+New-Item -ItemType Directory -Path $itemsDir -Force | Out-Null
+
+# ---------- 数据源 ----------
+$sfOwner = 'saltedfishclub'
+$sfRepo = 'sfcraft'
+$sfRef = 'rev/26.2'
+$sfDir = 'src/main/resources/assets/sfcraft/textures/item'
+$sfListUrl = "https://api.github.com/repos/$sfOwner/$sfRepo/contents/$sfDir`?ref=$sfRef"
+$wikiApi = 'https://zh.minecraft.wiki/api.php'
+$wikiImg = 'https://zh.minecraft.wiki/images/'
+
+function Write-Step([string]$s) { Write-Host "`n== $s" -ForegroundColor Cyan }
+
+function Invoke-Get([string]$url, [int]$maxRetry = 2) {
+ for ($i = 0; ; $i++) {
+ try {
+ return Invoke-RestMethod -Uri $url -Headers @{ 'User-Agent' = $ua } -TimeoutSec 30
+ } catch {
+ if ($i -ge $maxRetry) { throw }
+ Start-Sleep -Milliseconds 800
+ }
+ }
+}
+
+# ---------- 1. sfcraft 自定义贴图 ----------
+Write-Step '1/4 同步 sfcraft 自定义贴图'
+$sfCount = 0
+$sfNew = 0
+try {
+ $list = Invoke-Get $sfListUrl
+ foreach ($f in $list) {
+ $target = Join-Path $itemsDir $f.name
+ $need = $Force -or
+ -not (Test-Path -LiteralPath $target) -or
+ ((Get-Item -LiteralPath $target).Length -ne [int64]$f.size)
+ if ($need) {
+ Invoke-WebRequest -Uri $f.download_url -Headers @{ 'User-Agent' = $ua } -OutFile $target -TimeoutSec 60
+ $sfNew++
+ }
+ $sfCount++
+ }
+ Write-Host " $sfCount 个文件, 本次更新 $sfNew 个"
+} catch {
+ Write-Host " [警告] 无法访问 GitHub, 跳过 sfcraft 贴图同步: $($_.Exception.Message)" -ForegroundColor Yellow
+}
+
+# ---------- 2. 扫描 crafting 组件 ----------
+Write-Step '2/4 扫描组件物品 id'
+$ids = @{}
+$shortcodePat = [regex]'(?s){{<[ ]*crafting\b(.*?)}}'
+$paramPat = [regex]'(?:a[123]|b[123]|c[123]|out)="([^"]*)"'
+
+Get-ChildItem -Path $contentDir -Recurse -Filter '*.md' -File | ForEach-Object {
+ $text = Get-Content -LiteralPath $_.FullName -Raw -Encoding UTF8
+ foreach ($m in $shortcodePat.Matches($text)) {
+ foreach ($p in $paramPat.Matches($m.Groups[1].Value)) {
+ $val = $p.Groups[1].Value.Trim()
+ if ($val.Length -gt 0) {
+ $id = ($val -split ':')[0].Trim()
+ if ($id.Length -gt 0) { $ids[$id] = $true }
+ }
+ }
+ }
+}
+
+$all = @($ids.Keys | Sort-Object)
+$need = @($all | Where-Object { -not (Test-Path -LiteralPath (Join-Path $itemsDir "${_}.png")) })
+Write-Host " 使用 $($all.Count) 个物品 id, 本地已有 $($all.Count - $need.Count) 个"
+
+# ---------- 3. 从 Minecraft wiki 获取缺失贴图 ----------
+Write-Step '3/4 从 Minecraft wiki 获取缺失贴图'
+
+function Get-WikiCandidates([string]$name) {
+ $regex = '^' + [regex]::Escape($name) + '(_\(item\))?_JE\d+(\.\d+)*(_BE\d+)?\.png$'
+ # 两个查询互补:
+ # _ 宽前缀, 覆盖 (item) 等变体, 如 Diamond_Boots_(item)_JE3_BE2.png
+ # _JE 窄前缀, 在宽前缀因数量被截断时兜底(裸物品如 diamond 的 JE 渲染排序靠前)
+ $urls = @(
+ "$wikiApi`?action=query&list=allimages&aiprefix=$([uri]::EscapeDataString($name + '_'))&ailimit=500&format=json",
+ "$wikiApi`?action=query&list=allimages&aiprefix=$([uri]::EscapeDataString($name + '_JE'))&ailimit=200&format=json"
+ )
+ $seen = @{}
+ $all = New-Object System.Collections.ArrayList
+ foreach ($u in $urls) {
+ try {
+ $r = Invoke-Get $u
+ if ($null -ne $r.query.allimages) {
+ foreach ($n in $r.query.allimages) {
+ if (-not $seen.ContainsKey($n.name)) {
+ [void]$seen.Add($n.name, $true)
+ [void]$all.Add($n.name)
+ }
+ }
+ }
+ } catch {
+ Write-Host " [警告] wiki 查询失败 ($name): $($_.Exception.Message)" -ForegroundColor Yellow
+ }
+ Start-Sleep -Milliseconds 250
+ }
+ return @($all | Where-Object { $_ -match $regex })
+}
+
+function Select-BestCandidate([string]$name, [string[]]$candidates) {
+ $regex = '^' + [regex]::Escape($name) + '(_\(item\))?_JE(\d+)(\.\d+)*(_BE(\d+))?\.png$'
+ $best = $null
+ $bestKey = $null
+ foreach ($c in $candidates) {
+ $m = [regex]::Match($c, $regex)
+ if (-not $m.Success) { continue }
+ $key = @(
+ $(if ($m.Groups[1].Success) { 1 } else { 0 }), # 优先物品图标 (item)
+ [int]$m.Groups[2].Value, # JE 版本
+ $(if ($m.Groups[5].Success) { [int]$m.Groups[5].Value } else { -1 }) # BE 版本
+ )
+ if ($null -eq $bestKey) { $best = $c; $bestKey = $key; continue }
+ for ($i = 0; $i -lt 3; $i++) {
+ if ($key[$i] -ne $bestKey[$i]) {
+ if ($key[$i] -gt $bestKey[$i]) { $best = $c; $bestKey = $key }
+ break
+ }
+ }
+ }
+ return $best
+}
+
+function Get-NameVariants([string]$id) {
+ $words = @($id -split '_' | Where-Object { $_ })
+ $title = @($words | ForEach-Object { $_.Substring(0, 1).ToUpper() + $_.Substring(1) }) -join '_'
+
+ $connectors = @('and', 'of', 'the', 'for', 'with', 'to', 'from', 'by', 'in', 'on', 'at')
+ $mixed = @($words | ForEach-Object {
+ if ($connectors -contains $_) { $_ } else { $_.Substring(0, 1).ToUpper() + $_.Substring(1) }
+ }) -join '_'
+
+ $variants = New-Object System.Collections.ArrayList
+ [void]$variants.Add($title)
+ if ($mixed -cne $title) { [void]$variants.Add($mixed) }
+ if ($id.Length -le 4 -and $id -notmatch '[aeiou]') { [void]$variants.Add($id.ToUpper()) }
+ $oneWord = @($words | ForEach-Object { $_.Substring(0, 1).ToUpper() + $_.Substring(1) }) -join ''
+ if ($oneWord -cne $title) { [void]$variants.Add($oneWord) }
+
+ $aliases = @{
+ 'tnt' = 'TNT'
+ 'tnt_minecart' = 'TNT_Minecart'
+ 'dragon_breath' = "Dragon's_Breath"
+ 'slime_ball' = 'Slime'
+ 'snow_golem' = 'Snow_Golem'
+ 'ocelot' = 'Ocelot'
+ }
+ if ($aliases.ContainsKey($id)) { [void]$variants.Insert(0, $aliases[$id]) }
+ return @($variants | Select-Object -Unique)
+}
+
+$failed = @()
+foreach ($id in $need) {
+ $file = $null
+ foreach ($name in Get-NameVariants $id) {
+ $cands = Get-WikiCandidates $name
+ if ($cands.Count -gt 0) { $file = Select-BestCandidate $name $cands; break }
+ }
+
+ if ($file) {
+ $target = Join-Path $itemsDir "$id.png"
+ try {
+ Invoke-WebRequest -Uri ($wikiImg + [uri]::EscapeDataString($file)) -Headers @{ 'User-Agent' = $ua } -OutFile $target -TimeoutSec 60
+ $fs = [System.IO.File]::OpenRead($target)
+ $sig = New-Object byte[] 8
+ [void]$fs.Read($sig, 0, 8)
+ $fs.Close()
+ if (-not ($sig[0] -eq 137 -and $sig[1] -eq 80 -and $sig[2] -eq 78 -and $sig[3] -eq 71)) {
+ throw '下载内容不是有效 PNG'
+ }
+ Write-Host " ok $id <- $file" -ForegroundColor Green
+ continue
+ } catch {
+ Write-Host " fail $id 下载失败: $($_.Exception.Message)" -ForegroundColor Yellow
+ }
+ } else {
+ Write-Host " fail $id 在 wiki 上未找到" -ForegroundColor Yellow
+ }
+ $failed += $id
+}
+
+# ---------- 4. 汇总错误 ----------
+if ($failed.Count -gt 0) {
+ Write-Host "`n[错误] 以下物品贴图无法自动获取:" -ForegroundColor Red
+ $failed | Sort-Object | ForEach-Object { Write-Host " - $_" -ForegroundColor Red }
+ Write-Host @"
+
+可能原因:
+ 1. 物品 id 拼写错误(组件中填写的是 assets/items 下的文件名)
+ 2. 该物品是 sfcraft 自定义物品, 但 textures/item 目录中没有同名贴图
+ 3. 原版物品在 Minecraft wiki 上的命名特殊, 未被自动规则覆盖
+
+解决办法:
+ - 将贴图手动放入 assets/items/.png 后重新运行
+ - 修正组件中的物品 id
+ - 若是 wiki 命名特例, 可在 scripts/sync-items.ps1 的 Get-NameVariants 别名表中补充
+"@ -ForegroundColor Yellow
+ exit 1
+}
+
+Write-Host "`n全部贴图就绪 ✓" -ForegroundColor Green
+exit 0
diff --git a/themes/hugo-book b/themes/hugo-book
new file mode 160000
index 0000000..bc3454c
--- /dev/null
+++ b/themes/hugo-book
@@ -0,0 +1 @@
+Subproject commit bc3454c1db956f651dbcc4cf5056861fd9128817