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
-2
View File
@@ -2,5 +2,3 @@ public
themes/hugo-book/**
resources/_gen
*.log
assets/items/*.png
__pycache__/
-34
View File
@@ -1,34 +0,0 @@
# SFCraft 文档 · 构建 / 开发入口 (跨平台, 脚本为 Python 3)
#
# make build / make dev / make sync / make help
#
# 可附加 hugo 参数: make dev HUGO_ARGS="--port 1314"
# 可指定解释器: make PY=python3 build
.PHONY: help sync build dev
HUGO_ARGS ?=
ifeq ($(OS),Windows_NT)
PY ?= python
else
PY ?= python3
endif
help:
@echo SFCraft 文档
@echo make build 同步物品贴图并执行 hugo 构建
@echo make dev 同步物品贴图并启动 hugo 开发服务器
@echo make sync 仅同步物品贴图
@echo make help 显示本帮助
@echo
@echo 可附加 hugo 参数: make dev HUGO_ARGS="--port 1314"
sync:
$(PY) scripts/sync-items.py
build:
$(PY) scripts/build.py $(HUGO_ARGS)
dev:
$(PY) scripts/dev.py $(HUGO_ARGS)
+117
View File
@@ -0,0 +1,117 @@
.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
);
}
/* 悬停显示物品 id */
.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;
}
+59 -18
View File
@@ -1,26 +1,68 @@
# 物品贴图
# 物品贴图
本目录存放 `crafting` 合成组件使用的物品贴图PNG
`crafting` 合成组件的物品贴图**不需要手工准备**:构建时由 Hugo 自己解析、下载并缓存
`a1="diamond"` 就会显示钻石,不用先把 `diamond.png` 放进仓库,也不用跑任何同步脚本。
## 命名规则
直接 `hugo` / `hugo server` 即可。
一个物品对应一个文件:`<名称>.png`,文件名(不含 `.png`)就是引用时的名字。
## 解析顺序
例如放入 `diamond.png` 后,在合成组件中写 `a1="diamond"` 即可显示钻石。
每个物品 id 依次尝试,命中即止(实现见 `layouts/_partials/sfcraft/item-texture.html`):
> 推荐使用 16×16(或 32×32)的 Minecraft 像素风贴图,`image-rendering: pixelated` 会保证缩放后依然清晰锐利。
1. **本目录** `assets/items/<id>.png` —— 手动放入的贴图,优先级最高;
2. **sfcraft 仓库** `textures/item/<id>.png` —— 自定义物品,版本由 `hugo.yaml`
`params.itemTextures.repo.baseURL` 的 ref 决定;
3. **Minecraft wiki** —— 原版物品,按 `<英文名>_JE<版本>[_BE<版本>].png` 的命名
列出候选并自动挑选最新版本渲染。
## 自动同步(推荐)
无论来自哪一层,贴图最终都发布到 `/sfc/items/<id>.png`
所以把某个物品从 wiki 换成手动贴图不会改变页面里的 URL。
构建 / 开发时由 `scripts/sync-items.py` 自动维护本目录:
## 缓存
- 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,阻止构建 / 开发。
抓取结果写进 Hugo 的 `getresource` 文件缓存,`hugo.yaml` 里设为永不过期,
因此每个 URL 全局只下载一次:
请使用 `python scripts\build.py` / `python scripts\dev.py` 而非直接运行 `hugo`,详见 `scripts/README.md`
- 首次构建需要联网(本仓库当前的配方约需数秒);
- 之后的构建全部命中缓存,**离线也能完整构建**;
- 需要拉取上游更新(sfcraft 换了贴图、wiki 出了新版本渲染)时执行 `hugo --ignoreCache`
缓存默认在 `hugo config | grep cachedir` 指向的目录。CI 里建议把它固定下来并缓存该目录:
```bash
hugo --cacheDir "$PWD/.hugo-cache"
```
这样只有第一次构建需要访问 `raw.githubusercontent.com``zh.minecraft.wiki`
若 CI 完全不允许联网,把需要的贴图提交到本目录即可(第 1 层优先级最高)。
## 手动放入贴图
需要覆盖上游贴图,或某个物品自动解析不到时,把文件放到 `assets/items/<id>.png`
文件名(不含 `.png`)就是引用时的 id。这些文件会提交进仓库。
> 推荐 16×16 或 32×32 的像素风贴图,`image-rendering: pixelated` 会保证放大后依然锐利。
## 解析不到怎么办
构建会报错并指出页面与物品 id
```
ERROR /vanilla/items/xxx 引用的物品 "diamnod" 没有对应贴图。请检查 id 拼写, 或把贴图放进 assets/items/diamnod.png
```
常见原因与处理:
- **id 拼错** —— 改正 id
- **wiki 上的英文名不符合自动推导规则**(如 `TNT``Dragon's_Breath`
—— 在 `data/sfcraft/wiki_aliases.yaml` 里补一条 id → 英文名的映射;
- **确实没有现成贴图** —— 手动放进 `assets/items/`
网络故障、被限流或上游异常时不会被当成「id 写错」:这类情况只告警并显示 `?` 占位符,
不阻断构建,方便离线时继续写文档。
想让缺失贴图也不阻断构建,可把 `hugo.yaml``params.itemTextures.onMissing`
`error` 改成 `warn``ignore`
## 组件用法
@@ -35,10 +77,9 @@
```
- `a1`~`c3`3×3 合成格的九个格子,`out`:输出格;
- 值 = 贴图文件名(不含 `.png`,**留空 / 省略 = 空格子**;
- 值 = 物品 id**留空 / 省略 / `.` = 空格子**
- 需要显示堆叠数量时写 `名称:数量`,例如 `b2="stick:2"`
- `caption` 可选,显示在组件下方居中说明文字
- 未运行自动同步且贴图缺失时,格子内会显示一个 `?` 占位,方便发现写错的名字。
- `caption` 可选,显示在组件下方居中说明文字
### pattern 别名模式(另一种写法)
@@ -58,5 +99,5 @@
- `pattern` 共 9 个 token(3 行 × 3 列),**必须写在一行内**,行间用 `\n` 表示;
- 每个 token 对应一个同名参数(`a``b``c`…),参数值就是物品 id
- token 也可以直接写物品 id`pattern="diamond stick ."`
- `.` 表示空格子;
- `.` 表示空格子(写在 pattern 里,或作为别名的值,都算空格子)
- 同时给出 `pattern``a1`~`c3` 时,以 `pattern` 为准(`out` 两者通用)。
+29
View File
@@ -0,0 +1,29 @@
# Minecraft wiki 图片命名特例表
#
# 原版物品贴图的英文名默认由物品 id 自动推导 (逐词首字母大写),
# 例如 diamond_pickaxe -> Diamond_Pickaxe。
#
# 自动规则覆盖不到的命名写在下面, 左边是物品 id, 右边是 wiki 上的英文名。
# 构建时报告某个原版物品"未找到", 但它在 wiki 上确实存在时, 在这里补一条即可。
aliases:
tnt: TNT
tnt_minecart: TNT_Minecart
dragon_breath: Dragon's_Breath
slime_ball: Slime
snow_golem: Snow_Golem
ocelot: Ocelot
# 英文名中保持小写的连接词, 例如 flint_and_steel -> Flint_and_Steel
connectors:
- and
- of
- the
- for
- with
- to
- from
- by
- in
- on
- at
+27
View File
@@ -4,12 +4,39 @@ defaultContentLanguage: zh
title: SFCraft 文档
theme: hugo-book
module:
hugoVersion:
# layouts/_partials 与 layouts/_shortcodes 目录需要 0.146+,
# 贴图解析用到的 try 需要 0.141+, hugo.Data 需要 0.156+
min: 0.156.0
params:
BookTheme: auto # 明/暗跟随系统
BookSection: '*' # 每个顶层 section 渲染为侧边栏一个栏目
BookToC: true
BookSearch: true
# crafting 组件的物品贴图解析, 见 layouts/_partials/sfcraft/item-texture.html
itemTextures:
localDir: items # 手动放置贴图的目录 (assets/<localDir>/<id>.png)
publishDir: sfc/items # 贴图发布到站点内的路径
userAgent: sfcraft-docs (+https://github.com/saltedfishclub/sfcraft-docs)
onMissing: error # 贴图确实不存在时: error 阻断构建 / warn 仅告警 / ignore 静默
repo:
enable: true
# sfcraft 自定义物品贴图; 换版本时改这里的 ref 即可
baseURL: https://raw.githubusercontent.com/saltedfishclub/sfcraft/rev/26.2/src/main/resources/assets/sfcraft/textures/item/
wiki:
enable: true
api: https://zh.minecraft.wiki/api.php
# 远程贴图与 wiki 查询的抓取缓存: 永不过期, 落在 cacheDir 下。
# 首次构建后即可离线构建; 需要拉取上游更新时执行 hugo --ignoreCache。
caches:
getresource:
dir: :cacheDir/:project
maxAge: -1
markup:
goldmark:
renderer:
-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" -}}
-70
View File
@@ -1,70 +0,0 @@
# 构建 / 开发脚本
## 用途
`scripts/` 下全部为 Python 3 脚本(无其他语言):
- `sync-items.py` —— 物品贴图自动同步(核心);
- `build.py` —— 同步贴图 + hugo 构建;
- `dev.py` —— 同步贴图 + hugo 开发服务器。
同步流程:
1. 下载 [sfcraft 仓库](https://github.com/saltedfishclub/sfcraft/tree/rev/26.2/src/main/resources/assets/sfcraft/textures/item) 的
全部自定义贴图到 `assets/items/`;
2. 扫描 `content/` 中所有 `crafting` 组件用到的物品 id(支持九格直填与 pattern 别名两种写法);
3. 本地缺失的原版物品从 Minecraft wiki 自动获取(自动挑选最新版本渲染);
4. 有任何物品无法找到时, 打印缺失清单并以非零码退出, 阻断构建 / 开发启动。
## 使用
Windows:
```powershell
python scripts\build.py # 同步贴图 + hugo 构建
python scripts\dev.py # 同步贴图 + hugo server
python scripts\dev.py --no-sync # 仅启动开发服务器, 不重新同步
python scripts\sync-items.py -f # 仅同步贴图(强制刷新 sfcraft 贴图)
```
Linux / macOS:
```bash
python3 scripts/build.py
python3 scripts/dev.py
python3 scripts/dev.py --no-sync
```
也可以用 MakefileWindows / Unix 通用):
```bash
make build # 同步贴图 + hugo 构建
make dev # 同步贴图 + hugo server
make sync # 仅同步贴图
make dev HUGO_ARGS="--port 1314"
make PY=python build # 指定 Python 解释器
```
脚本按 `$HUGO` 环境变量、PATH、winget 安装目录的顺序查找 hugo。
## 组件写法
`crafting` 组件支持两种设置方式, 详见 `assets/items/README.md`:
- 九格直填: `a1="diamond" a2="stick" ... out="diamond_pickaxe"`;
- pattern 别名: `pattern="a a a\nb b b\nc c c"` + `a="diamond" b="stick"`
贴图同步扫描器对两种写法都会收集物品 id。
## 规则细节
- sfcraft 自定义贴图: 每次构建比对远端文件大小, 缺失或变化时更新;
- 原版贴图: 只在本地缺失时下载, **不会覆盖**手动放入 `assets/items/` 的贴图;
- 物品 id 即 `assets/items/` 下的文件名(不含 `.png`);
- wiki 命名规则: `<英文名>_JE<版本>[_BE<版本>].png`, 特殊命名(如 `TNT``Flint_and_Steel`)
`sync-items.py` 中的变体生成与别名表处理。
## 网络要求
需要能访问 `api.github.com``raw.githubusercontent.com``zh.minecraft.wiki`
若 CI 或离线环境不允许联网, 建议把 `assets/items/` 下的贴图提交进仓库。
-60
View File
@@ -1,60 +0,0 @@
#!/usr/bin/env python3
"""SFCraft 文档构建入口: 先同步物品贴图, 再执行 hugo 构建。
贴图同步失败(有物品找不到)时终止构建。
用法:
python scripts/build.py [hugo 参数...]
"""
import os
import subprocess
import sys
from pathlib import Path
SCRIPTS = Path(__file__).resolve().parent
def find_hugo():
"""按 HUGO 环境变量、PATH、winget 安装目录依次查找 hugo。"""
env = os.environ.get("HUGO")
if env and Path(env).is_file():
return env
name = "hugo.exe" if os.name == "nt" else "hugo"
for d in os.environ.get("PATH", "").split(os.pathsep):
if d:
cand = Path(d) / name
if cand.is_file():
return str(cand)
local = os.environ.get("LOCALAPPDATA")
if local:
base = Path(local) / "Microsoft" / "WinGet" / "Packages"
if base.is_dir():
for p in sorted(base.glob("*/hugo.exe")):
return str(p)
return None
def main():
print("\n[1/2] 同步物品贴图")
r = subprocess.run([sys.executable, str(SCRIPTS / "sync-items.py")])
if r.returncode != 0:
print("[构建终止] 物品贴图同步失败, 未执行 hugo 构建。", file=sys.stderr)
sys.exit(r.returncode)
hugo = find_hugo()
if not hugo:
print(
"[错误] 未找到 hugo, 请安装 Hugo、将其加入 PATH, 或设置环境变量 HUGO。",
file=sys.stderr,
)
sys.exit(1)
print("\n[2/2] hugo 构建")
sys.exit(subprocess.run([hugo, *sys.argv[1:]]).returncode)
if __name__ == "__main__":
main()
-71
View File
@@ -1,71 +0,0 @@
#!/usr/bin/env python3
"""SFCraft 文档开发服务器入口: 先同步物品贴图, 再启动 hugo server。
贴图同步失败(有物品找不到)时终止启动。
用法:
python scripts/dev.py [--no-sync] [hugo server 参数...]
"""
import argparse
import os
import subprocess
import sys
from pathlib import Path
SCRIPTS = Path(__file__).resolve().parent
def find_hugo():
"""按 HUGO 环境变量、PATH、winget 安装目录依次查找 hugo。"""
env = os.environ.get("HUGO")
if env and Path(env).is_file():
return env
name = "hugo.exe" if os.name == "nt" else "hugo"
for d in os.environ.get("PATH", "").split(os.pathsep):
if d:
cand = Path(d) / name
if cand.is_file():
return str(cand)
local = os.environ.get("LOCALAPPDATA")
if local:
base = Path(local) / "Microsoft" / "WinGet" / "Packages"
if base.is_dir():
for p in sorted(base.glob("*/hugo.exe")):
return str(p)
return None
def main():
parser = argparse.ArgumentParser(description="SFCraft 开发服务器")
parser.add_argument(
"--no-sync", "-NoSync", action="store_true",
help="跳过贴图同步",
)
args, rest = parser.parse_known_args()
if not args.no_sync:
print("\n[1/2] 同步物品贴图")
r = subprocess.run([sys.executable, str(SCRIPTS / "sync-items.py")])
if r.returncode != 0:
print("[启动终止] 物品贴图同步失败, 未启动 hugo server。", file=sys.stderr)
sys.exit(r.returncode)
else:
print("[跳过] 已指定 --no-sync, 不执行贴图同步")
hugo = find_hugo()
if not hugo:
print(
"[错误] 未找到 hugo, 请安装 Hugo、将其加入 PATH, 或设置环境变量 HUGO。",
file=sys.stderr,
)
sys.exit(1)
print("\n[2/2] 启动 hugo server")
sys.exit(subprocess.run([hugo, "server", *rest]).returncode)
if __name__ == "__main__":
main()
-264
View File
@@ -1,264 +0,0 @@
#!/usr/bin/env python3
"""SFCraft 文档 · 物品贴图自动同步 (Python 3)
1. 下载 sfcraft 仓库 textures/item 全部贴图到 assets/items
2. 扫描 content 中 crafting 组件用到的物品 id (支持九格直填与 pattern 别名两种写法)
3. 本地缺失的 id 从 Minecraft wiki 自动获取(自动挑选最新版本渲染)
4. 仍找不到 -> 列出缺失物品并以退出码 1 结束
用法:
python3 scripts/sync-items.py [-f]
"""
import argparse
import json
import re
import sys
import time
import urllib.parse
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
ITEMS_DIR = ROOT / "assets" / "items"
CONTENT_DIR = ROOT / "content"
UA = "sfcraft-docs-sync/1.0 (https://github.com/saltedfishclub/sfcraft-docs)"
SF_OWNER = "saltedfishclub"
SF_REPO = "sfcraft"
SF_REF = "rev/26.2"
SF_DIR = "src/main/resources/assets/sfcraft/textures/item"
SF_LIST_URL = (
f"https://api.github.com/repos/{SF_OWNER}/{SF_REPO}/contents/{SF_DIR}?ref={SF_REF}"
)
WIKI_API = "https://zh.minecraft.wiki/api.php"
WIKI_IMG = "https://zh.minecraft.wiki/images/"
SHORTCODE_RE = re.compile(r"\{\{<\s*crafting\b(.*?)\}\}", re.S)
PAIR_RE = re.compile(r'(\w+)="([^"]*)"')
SLOT_KEYS = ["a1", "a2", "a3", "b1", "b2", "b3", "c1", "c2", "c3", "out"]
CONNECTORS = {"and", "of", "the", "for", "with", "to", "from", "by", "in", "on", "at"}
ALIASES = {
"tnt": "TNT",
"tnt_minecart": "TNT_Minecart",
"dragon_breath": "Dragon's_Breath",
"slime_ball": "Slime",
"snow_golem": "Snow_Golem",
"ocelot": "Ocelot",
}
def http_get_json(url: str, retries: int = 2) -> dict:
for i in range(retries + 1):
try:
req = urllib.request.Request(url, headers={"User-Agent": UA})
with urllib.request.urlopen(req, timeout=30) as resp:
return json.load(resp)
except Exception: # noqa: BLE001
if i < retries:
time.sleep(0.8)
else:
raise
# 理论上不可达(retries 为负时 range 为空才可能走到), 兜底避免隐式返回 None
raise RuntimeError(f"请求失败(无可用异常信息): {url}")
def http_download(url, target):
req = urllib.request.Request(url, headers={"User-Agent": UA})
with urllib.request.urlopen(req, timeout=60) as resp, open(target, "wb") as f:
f.write(resp.read())
with open(target, "rb") as f:
if f.read(4) != b"\x89PNG":
raise ValueError("下载内容不是有效 PNG")
def add_item_id(ids, raw):
v = raw.strip()
if not v:
return
item = v.split(":", 1)[0].strip()
if item:
ids.add(item)
def scan_item_ids():
"""扫描 content 下所有 crafting 组件, 收集物品 id。"""
ids: set[str] = set()
for path in sorted(CONTENT_DIR.rglob("*.md")):
try:
text = path.read_text(encoding="utf-8")
except OSError:
continue
for m in SHORTCODE_RE.finditer(text):
params = dict(PAIR_RE.findall(m.group(1)))
for k in SLOT_KEYS:
if k in params:
add_item_id(ids, params[k])
if "pattern" in params:
pat = re.sub(r"\\n|;|\r?\n", " ", params["pattern"])
for tok in pat.split():
if tok == ".":
continue
if tok in params:
add_item_id(ids, params[tok])
elif re.fullmatch(r"[a-z0-9_]+", tok):
ids.add(tok)
return ids
def name_variants(item_id):
"""由物品 id 生成可能的 wiki 英文名(含特殊命名变体与别名)。"""
words = [w for w in item_id.split("_") if w]
title = "_".join(w[0].upper() + w[1:] for w in words)
mixed = "_".join(w if w in CONNECTORS else w[0].upper() + w[1:] for w in words)
variants = [title]
if mixed != title:
variants.append(mixed)
if len(item_id) <= 4 and not re.search(r"[aeiou]", item_id):
variants.append(item_id.upper())
one_word = "".join(w[0].upper() + w[1:] for w in words)
if one_word != title:
variants.append(one_word)
if item_id in ALIASES:
variants.insert(0, ALIASES[item_id])
seen = set()
out = []
for v in variants:
if v not in seen:
seen.add(v)
out.append(v)
return out
def wiki_candidates(name):
"""查询 wiki 图片列表, 返回符合 <英文名>_JE..[_BE..].png 规则的文件名。"""
regex = re.compile(
r"^" + re.escape(name) + r"(_\(item\))?_JE\d+(\.\d+)*(_BE\d+)?\.png$"
)
urls = [
WIKI_API
+ "?action=query&list=allimages&aiprefix="
+ urllib.parse.quote(name + "_")
+ "&ailimit=500&format=json",
WIKI_API
+ "?action=query&list=allimages&aiprefix="
+ urllib.parse.quote(name + "_JE")
+ "&ailimit=200&format=json",
]
seen = set()
all_names = []
for url in urls:
try:
data = http_get_json(url)
for item in data.get("query", {}).get("allimages", []) or []:
nm = item["name"]
if nm not in seen:
seen.add(nm)
all_names.append(nm)
except Exception as e: # noqa: BLE001
print(f" [警告] wiki 查询失败 ({name}): {e}", file=sys.stderr)
time.sleep(0.25)
return [n for n in all_names if regex.match(n)]
def select_best(name, candidates):
"""挑选最新版本: 优先 (item) 物品图标, 再比 JE / BE 版本号。"""
regex = re.compile(
r"^" + re.escape(name) + r"(_\(item\))?_JE(\d+)(\.\d+)*(_BE(\d+))?\.png$"
)
best = None
best_key = None
for cand in candidates:
m = regex.match(cand)
if not m:
continue
key = (
1 if m.group(1) else 0,
int(m.group(2)),
int(m.group(5)) if m.group(5) else -1,
)
if best_key is None or key > best_key:
best, best_key = cand, key
return best
def main():
ap = argparse.ArgumentParser(description="SFCraft 物品贴图同步")
ap.add_argument("-f", "--force", action="store_true",
help="强制重新下载 sfcraft 自定义贴图")
args = ap.parse_args()
ITEMS_DIR.mkdir(parents=True, exist_ok=True)
# 1. sfcraft 自定义贴图
print("\n== 1/4 同步 sfcraft 自定义贴图")
sf_count = sf_new = 0
try:
listing = http_get_json(SF_LIST_URL)
for f in listing:
target = ITEMS_DIR / f["name"]
need = (
args.force
or not target.exists()
or target.stat().st_size != int(f["size"])
)
if need:
http_download(f["download_url"], target)
sf_new += 1
sf_count += 1
print(f" {sf_count} 个文件, 本次更新 {sf_new}")
except Exception as e: # noqa: BLE001
print(f" [警告] 无法访问 GitHub, 跳过 sfcraft 贴图同步: {e}", file=sys.stderr)
# 2. 扫描组件物品 id
print("\n== 2/4 扫描组件物品 id")
ids = scan_item_ids()
all_ids = sorted(ids)
need = [i for i in all_ids if not (ITEMS_DIR / f"{i}.png").exists()]
print(f" 使用 {len(all_ids)} 个物品 id, 本地已有 {len(all_ids) - len(need)}")
# 3. 从 Minecraft wiki 获取缺失贴图
print("\n== 3/4 从 Minecraft wiki 获取缺失贴图")
failed = []
for item_id in need:
file = None
for name in name_variants(item_id):
cands = wiki_candidates(name)
if cands:
file = select_best(name, cands)
break
if file:
try:
http_download(WIKI_IMG + urllib.parse.quote(file), ITEMS_DIR / f"{item_id}.png")
print(f" ok {item_id} <- {file}")
continue
except Exception as e: # noqa: BLE001
print(f" fail {item_id} 下载失败: {e}", file=sys.stderr)
else:
print(f" fail {item_id} 在 wiki 上未找到", file=sys.stderr)
failed.append(item_id)
# 4. 汇总错误
if failed:
print("\n[错误] 以下物品贴图无法自动获取:", file=sys.stderr)
for i in sorted(failed):
print(f" - {i}", file=sys.stderr)
print(
"\n可能原因:\n"
" 1. 物品 id 拼写错误(组件中填写的是 assets/items 下的文件名)\n"
" 2. 该物品是 sfcraft 自定义物品, 但 textures/item 目录中没有同名贴图\n"
" 3. 原版物品在 Minecraft wiki 上的命名特殊, 未被自动规则覆盖\n"
"\n解决办法:\n"
" - 将贴图手动放入 assets/items/<id>.png 后重新运行\n"
" - 修正组件中的物品 id\n"
" - 若是 wiki 命名特例, 可在 sync-items.py 的 ALIASES 表中补充",
file=sys.stderr,
)
sys.exit(1)
print("\n全部贴图就绪")
if __name__ == "__main__":
main()