refactor: resolve item textures in Hugo instead of sync scripts

The crafting shortcode previously depended on scripts/sync-items.py to
pre-populate assets/items/ before every build: the script regex-scanned
content/ for shortcode usages, listed the sfcraft repo via the GitHub API,
downloaded missing vanilla textures from the wiki, and had to be driven
through scripts/build.py, scripts/dev.py or a Makefile so that plain
`hugo` never ran on its own.

Resolution now happens inside the render pipeline, so `hugo` and
`hugo server` work directly and nothing has to be kept in sync:

- layouts/_partials/sfcraft/item-texture.html resolves an item id to an
  image Resource, trying assets/items/<id>.png, then the sfcraft repo
  texture, then the Minecraft wiki.
- layouts/_partials/sfcraft/wiki-lookup.html derives the wiki English
  name from the id, lists candidates via the allimages API and picks the
  newest JE/BE render, replacing the script's name-variant logic.
- Callers go through partialCached keyed on the item id, so each id is
  resolved once per build no matter how many slots reference it.

Because usages are discovered by rendering, the content scanner is gone
and the two syntaxes can no longer drift apart from what the scanner
understood. Enumerating the sfcraft repo is also unnecessary: a texture
is fetched by its raw URL and a 404 simply means "not a custom item".

Caching is Hugo's getresource file cache, pinned to maxAge -1, so every
URL is downloaded once globally, later builds hit the cache, and offline
builds succeed. `hugo --ignoreCache` refreshes upstream changes, which
replaces the script's per-build file-size comparison.

Error reporting distinguishes cases the script could not tell apart.
A 404 from every source means the id is wrong and fails the build
(configurable via params.itemTextures.onMissing), while a transport
error, rate limit or 5xx only warns and falls back to the `?`
placeholder, so a missing network no longer looks like a typo.

Also in this change:
- wiki name special cases move from a dict in the script to
  data/sfcraft/wiki_aliases.yaml
- textures publish to /sfc/items/<id>.png regardless of source, so
  switching an item to a hand-placed texture keeps its URL
- component CSS moves to assets/css/sfcraft-crafting.css, minified and
  inlined once per page, instead of a heredoc inside the shortcode
- a `.` used as an alias value now means "empty slot", matching what it
  already meant inside pattern; it previously resolved as an item id and
  reported a missing texture
- assets/items/*.png is no longer gitignored, since that directory now
  only holds intentional overrides that should be committed

Verified against Hugo 0.164.0: custom items resolve from the repo,
vanilla items from the wiki, TNT / Flint_and_Steel / Dragon's_Breath
exercise the alias and connector rules, hand-placed textures win over
both, a typo fails the build, and a cold cache with no network degrades
to placeholders while a warm cache builds fully offline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Claude
2026-08-08 17:57:38 +00:00
parent 9b171ed169
commit 1c46a2745c
15 changed files with 511 additions and 649 deletions
-29
View File
@@ -1,29 +0,0 @@
{{- $val := .val -}}
{{- $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 -}}
<div class="sfc-slot sfc-{{ .cls }}"
style="left: {{ .x }}; top: {{ .y }};"
{{ with $item }}data-name="{{ . }}"{{ end }}>
{{- if $img -}}
<img class="sfc-item" src="{{ $img.RelPermalink }}"
alt="{{ $item }}" loading="lazy">
{{- if gt $count 1 -}}
<span class="sfc-count">{{ $count }}</span>
{{- end -}}
{{- else if $item -}}
<span class="sfc-missing" title="未找到 assets/items/{{ $item }}.png">?</span>
{{- end -}}
</div>
@@ -0,0 +1,53 @@
{{- /* 合成表中的一个格子
入参:
val 格子内容, "物品id" 或 "物品id:数量", 空字符串表示空格子
cls "grid" (九宫格) 或 "out" (输出格)
x, y 在合成表底图上的定位
page 所在页面, 仅用于报错定位
*/ -}}
{{- $val := .val -}}
{{- $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 := false -}}
{{- if $item -}}
{{- /* 以 id 为 key, 同一物品每次构建只解析一次 */ -}}
{{- $texture := partialCached "sfcraft/item-texture.html" (dict "id" $item) $item -}}
{{- $img = $texture.image -}}
{{- if ne $texture.status "ok" -}}
{{- $where := printf "%s 引用的物品 %q" .page.Path $item -}}
{{- $onMissing := (site.Params.itemTextures | default dict).onMissing | default "error" -}}
{{- if eq $texture.status "degraded" -}}
{{- /* 离线 / 限流 / 上游异常: 结论不可靠, 只告警, 不阻断构建 */ -}}
{{- warnf "%s 贴图解析失败, 已降级为占位符: %s" $where $texture.detail -}}
{{- else if eq $onMissing "error" -}}
{{- errorf "%s 没有对应贴图。请检查 id 拼写, 或把贴图放进 assets/items/%s.png" $where $item -}}
{{- else if eq $onMissing "warn" -}}
{{- warnf "%s 没有对应贴图" $where -}}
{{- end -}}
{{- end -}}
{{- end -}}
<div class="sfc-slot sfc-{{ .cls }}"
style="left: {{ .x }}; top: {{ .y }};"
{{ with $item }}data-name="{{ . }}"{{ end }}>
{{- if $img -}}
<img class="sfc-item" src="{{ $img.RelPermalink }}"
alt="{{ $item }}" width="{{ $img.Width }}" height="{{ $img.Height }}" loading="lazy">
{{- if gt $count 1 -}}
<span class="sfc-count">{{ $count }}</span>
{{- end -}}
{{- else if $item -}}
<span class="sfc-missing" title="未找到物品 {{ $item }} 的贴图">?</span>
{{- end -}}
</div>
@@ -0,0 +1,93 @@
{{- /* SFCraft 物品贴图解析器
把物品 id 解析成一个图片 Resource, 全程由 Hugo 完成, 不需要外部同步脚本。
查找顺序 (命中即止):
1. assets/items/<id>.png 手动放入的贴图, 优先级最高
2. sfcraft 仓库 textures/item/<id>.png 自定义物品
3. Minecraft wiki 原版物品, 自动挑选最新版本渲染
抓取结果由 Hugo 的 getresource 文件缓存持久化 (hugo.yaml 中 maxAge: -1),
因此每个 URL 全局只下载一次; 后续构建离线也能完成。
入参:
id 物品 id (assets/items 下的文件名, 不含 .png)
返回 dict:
image 图片 Resource, 未解析出来时为 nil
source "local" / "sfcraft" / "wiki", 未解析出来时为 ""
status "ok" 已解析
"missing" 各来源都明确不存在该贴图 (id 很可能写错了)
"degraded" 有来源访问失败 (离线 / 限流 / 服务异常), 结论不可靠
detail status 非 ok 时的诊断信息
调用方应使用 partialCached 并以 id 作为 key, 使同一 id 每次构建只解析一次。
*/ -}}
{{- $id := .id -}}
{{- $cfg := site.Params.itemTextures | default dict -}}
{{- $repo := $cfg.repo | default dict -}}
{{- $wiki := $cfg.wiki | default dict -}}
{{- $localDir := $cfg.localDir | default "items" -}}
{{- $publishDir := $cfg.publishDir | default "sfc/items" -}}
{{- $opts := dict "headers" (dict "User-Agent" ($cfg.userAgent | default "sfcraft-docs")) -}}
{{- $image := false -}}
{{- $source := "" -}}
{{- $notes := slice -}}
{{- /* 1. 本地贴图 (手动放入的永远优先, 便于覆盖上游) */ -}}
{{- with resources.Get (printf "%s/%s.png" $localDir $id) -}}
{{- $image = . -}}
{{- $source = "local" -}}
{{- end -}}
{{- /* 2. sfcraft 仓库的自定义物品贴图
404 时 GetRemote 返回 nil 且不报错, 正好作为"该物品不是自定义物品"的信号,
可以直接落到下一个来源; 其余状态码 (403/429/5xx) 与网络故障会置 .Err。 */ -}}
{{- if and (not $image) (ne $repo.enable false) $repo.baseURL -}}
{{- $url := printf "%s%s.png" $repo.baseURL $id -}}
{{- with try (resources.GetRemote $url $opts) -}}
{{- with .Err -}}
{{- $notes = $notes | append (printf "sfcraft 仓库访问失败: %s" (replaceRE `^.*error calling GetRemote: ` "" (printf "%s" .))) -}}
{{- else with .Value -}}
{{- $image = . -}}
{{- $source = "sfcraft" -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{- /* 3. Minecraft wiki 的原版物品渲染图 */ -}}
{{- if and (not $image) (ne $wiki.enable false) $wiki.api -}}
{{- $lookup := partialCached "sfcraft/wiki-lookup.html"
(dict "id" $id "api" $wiki.api "opts" $opts) $id -}}
{{- with $lookup.notes -}}{{- $notes = $notes | append . -}}{{- end -}}
{{- with $lookup.url -}}
{{- with try (resources.GetRemote . $opts) -}}
{{- with .Err -}}
{{- $notes = $notes | append (printf "wiki 贴图下载失败 (%s): %s" $lookup.name (replaceRE `^.*error calling GetRemote: ` "" (printf "%s" .))) -}}
{{- else with .Value -}}
{{- $image = . -}}
{{- $source = "wiki" -}}
{{- else -}}
{{- $notes = $notes | append (printf "wiki 贴图不存在 (%s)" $lookup.name) -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{- /* 统一发布路径, 使贴图来源变化 (wiki -> 手动放入) 时页面上的 URL 不变 */ -}}
{{- $status := "missing" -}}
{{- if $image -}}
{{- $status = "ok" -}}
{{- $image = resources.Copy (printf "%s/%s.png" $publishDir $id) $image -}}
{{- else if $notes -}}
{{- /* 有来源没能给出确定答案, 不能断言 id 写错了 */ -}}
{{- $status = "degraded" -}}
{{- end -}}
{{- return dict
"image" (cond (eq $status "ok") $image nil)
"source" $source
"status" $status
"detail" (delimit $notes "; ") -}}
+116
View File
@@ -0,0 +1,116 @@
{{- /* 在 Minecraft wiki 上查出某个物品最新版本渲染图的 URL
wiki 的物品图片按 <英文名>_JE<版本>[_BE<版本>].png 命名, 同一物品会有多个历史版本,
这里用 allimages API 按前缀列出候选, 再挑版本号最大的一张。
英文名由物品 id 自动推导 (逐词首字母大写), 推不出来的特例写在
data/sfcraft/wiki_aliases.yaml 里。
入参:
id 物品 id
api wiki api.php 的地址
opts 传给 resources.GetRemote 的选项
返回 dict:
url 图片 URL, 没找到时为 ""
name 命中的 wiki 英文名
notes 访问失败等诊断信息 (字符串, 无异常时为 "")
*/ -}}
{{- $id := .id -}}
{{- $api := .api -}}
{{- $opts := .opts -}}
{{- $data := (hugo.Data.sfcraft | default dict).wiki_aliases | default dict -}}
{{- $aliases := $data.aliases | default dict -}}
{{- $connectors := $data.connectors | default slice -}}
{{- /* 生成英文名候选 */ -}}
{{- $words := slice -}}
{{- range split $id "_" -}}
{{- if ne . "" -}}{{- $words = $words | append . -}}{{- end -}}
{{- end -}}
{{- $titleWords := slice -}}
{{- $mixedWords := slice -}}
{{- range $words -}}
{{- $upper := strings.FirstUpper . -}}
{{- $titleWords = $titleWords | append $upper -}}
{{- $mixedWords = $mixedWords | append (cond (in $connectors .) . $upper) -}}
{{- end -}}
{{- $variants := slice -}}
{{- /* 特例表优先 */ -}}
{{- with index $aliases $id -}}{{- $variants = $variants | append . -}}{{- end -}}
{{- /* diamond_pickaxe -> Diamond_Pickaxe */ -}}
{{- $variants = $variants | append (delimit $titleWords "_") -}}
{{- /* flint_and_steel -> Flint_and_Steel */ -}}
{{- $variants = $variants | append (delimit $mixedWords "_") -}}
{{- /* tnt -> TNT (短且无元音的 id 多为缩写) */ -}}
{{- if and (le (len $id) 4) (not (findRE "[aeiou]" $id)) -}}
{{- $variants = $variants | append (upper $id) -}}
{{- end -}}
{{- /* end_rod -> EndRod */ -}}
{{- $variants = $variants | append (delimit $titleWords "") -}}
{{- $variants = $variants | uniq -}}
{{- $bestURL := "" -}}
{{- $bestName := "" -}}
{{- $bestScore := -1 -}}
{{- $notes := slice -}}
{{- range $variants -}}
{{- $name := . -}}
{{- if not $bestURL -}}
{{- $query := querify
"action" "query"
"list" "allimages"
"aiprefix" (printf "%s_" $name)
"ailimit" 500
"format" "json" -}}
{{- $url := printf "%s?%s" $api $query -}}
{{- $images := slice -}}
{{- with try (resources.GetRemote $url $opts) -}}
{{- with .Err -}}
{{- $notes = $notes | append (printf "wiki 查询失败 (%s): %s" $name (replaceRE `^.*error calling GetRemote: ` "" (printf "%s" .))) -}}
{{- else with .Value -}}
{{- $images = (index (. | transform.Unmarshal) "query" "allimages") | default slice -}}
{{- else -}}
{{- /* API 对合法查询总会返回 200, 拿到 nil 说明请求没真正到达 */ -}}
{{- $notes = $notes | append (printf "wiki 查询无响应 (%s)" $name) -}}
{{- end -}}
{{- end -}}
{{- /* 从候选里挑版本最新的一张: 优先 _(item) 物品图标, 再比 JE 版本, 最后比 BE 版本 */ -}}
{{- range $images -}}
{{- $prefixItem := printf "%s_(item)_JE" $name -}}
{{- $prefixPlain := printf "%s_JE" $name -}}
{{- $rest := "" -}}
{{- $isItem := 0 -}}
{{- if hasPrefix .name $prefixItem -}}
{{- $rest = strings.TrimPrefix $prefixItem .name -}}
{{- $isItem = 1 -}}
{{- else if hasPrefix .name $prefixPlain -}}
{{- $rest = strings.TrimPrefix $prefixPlain .name -}}
{{- end -}}
{{- /* $rest 形如 "2_BE2.png" / "1.20.png" */ -}}
{{- if and $rest (findRE `^[0-9]+(\.[0-9]+)*(_BE[0-9]+)?\.png$` $rest) -}}
{{- $je := int (index (findRE `^[0-9]+` $rest) 0) -}}
{{- $be := -1 -}}
{{- with findRE `_BE[0-9]+` $rest -}}
{{- $be = int (strings.TrimPrefix "_BE" (index . 0)) -}}
{{- end -}}
{{- $score := add (mul $isItem 1000000) (add (mul $je 1000) (add $be 1)) -}}
{{- if gt $score $bestScore -}}
{{- $bestScore = $score -}}
{{- $bestURL = .url -}}
{{- $bestName = .name -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{- return dict
"url" $bestURL
"name" $bestName
"notes" (delimit $notes "; ") -}}
+17 -101
View File
@@ -1,4 +1,5 @@
{{- /* SFCraft 合成配方组件
两种用法:
1) 九格直填: a1..c3 为 3x3 合成格, out 为输出格
2) pattern 别名模式(提供 pattern 时优先):
@@ -6,9 +7,9 @@
a="diamond" b="stick" c="" 每个 token 对应一个同名参数, 值为物品 id
token 也可以直接写物品 id, 如 pattern="diamond stick ."
"." 表示空格子
- 值为 assets/items/ 下贴图的文件名(不含 .png), 留空表示空格子
- 名称后可加 ":数量" 显示堆叠数, 例如 a1="stick:2"
- 引用的贴图不存在时, 格子内显示 ? 占位提示
- 值为物品 id, 留空表示空格子; 名称后可加 ":数量" 显示堆叠数, 例如 a1="stick:2"
- 贴图由 partials/sfcraft/item-texture.html 自动解析并缓存, 无需手工准备
- caption 参数可选, 显示在组件下方
*/ -}}
@@ -36,100 +37,12 @@
{{- end -}}
{{- end -}}
{{- $cssOnce := .Page.Store.Get "sfc_crafting_css" -}}
{{- if not $cssOnce -}}
{{- /* 每页只输出一次样式 */ -}}
{{- if not (.Page.Store.Get "sfc_crafting_css") -}}
{{- .Page.Store.Set "sfc_crafting_css" true -}}
<style>
.sfc-crafting {
position: relative;
width: 100%;
max-width: 540px;
aspect-ratio: 320 / 160;
margin: 1rem 0 1.25rem;
background-repeat: no-repeat;
background-size: 100% 100%;
container-type: inline-size;
}
.sfc-slot {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
}
.sfc-grid { width: 10.625%; height: 21.25%; }
.sfc-out { width: 15.625%; height: 31.25%; }
.sfc-item {
width: 88%;
height: 88%;
object-fit: contain;
image-rendering: pixelated;
filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.35));
}
.sfc-count {
position: absolute;
right: 5%;
bottom: 3%;
font-family: "Minecraft", ui-monospace, Consolas, monospace;
font-size: 12px;
font-size: 2.8cqw;
font-weight: 700;
line-height: 1;
color: #fff;
text-shadow: 0 1px 0 #3f3f3f, 1px 0 0 #3f3f3f,
0 -1px 0 #3f3f3f, -1px 0 0 #3f3f3f,
0 2px 3px rgba(0, 0, 0, 0.55);
}
.sfc-missing {
display: flex;
align-items: center;
justify-content: center;
width: 86%;
height: 86%;
font-size: 4cqw;
font-weight: 700;
color: rgba(0, 0, 0, 0.28);
border: 2px dashed rgba(0, 0, 0, 0.18);
border-radius: 4px;
background:
repeating-linear-gradient(45deg, rgba(255,255,255,0.22), rgba(255,255,255,0.22) 4px, rgba(0,0,0,0.04) 4px, rgba(0,0,0,0.04) 8px);
}
.sfc-slot[data-name]::after {
content: attr(data-name);
position: absolute;
bottom: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
padding: 3px 9px;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 5px;
background: rgba(18, 18, 18, 0.92);
color: #fff;
font-size: 12px;
white-space: nowrap;
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.35);
opacity: 0;
pointer-events: none;
transition: opacity 0.12s ease;
z-index: 30;
}
.sfc-slot[data-name]:hover::after { opacity: 1; }
.sfc-caption {
margin: 0.35rem 0 0;
text-align: center;
font-size: 0.85rem;
color: var(--gray-600, #6c757d);
}
.sfc-error {
max-width: 540px;
margin: 1rem 0;
padding: 0.6rem 1rem;
border: 1px solid #f5c2c7;
border-radius: 6px;
background: #f8d7da;
color: #842029;
font-size: 0.9rem;
}
</style>
{{- with resources.Get "css/sfcraft-crafting.css" -}}
<style>{{ (. | minify).Content | safeCSS }}</style>
{{- end -}}
{{- end -}}
{{- if and $pattern (ne (len $tokens) 9) -}}
@@ -156,12 +69,15 @@
{{- else -}}
{{- $val = trim ($ctx.Get $slot.key) " " -}}
{{- end -}}
{{- partial "crafting-slot.html"
{{- /* "." 表示空格子, 无论它写在 pattern 里还是写成别名的值 */ -}}
{{- if eq $val "." -}}{{- $val = "" -}}{{- end -}}
{{- partial "sfcraft/crafting-slot.html"
(dict
"val" $val
"cls" $slot.cls
"x" $slot.x
"y" $slot.y
"val" $val
"cls" $slot.cls
"x" $slot.x
"y" $slot.y
"page" $ctx.Page
) -}}
{{- end -}}
{{- with $ctx.Get "caption" -}}