ci: check that referenced images exist; document shallow clone

断链图片一直在悄悄累积(本次清理前有 230 处),而且线上看不出来:
teedoc-upload 只上传不删除对象存储里已移除的文件,旧副本把断链盖住了,
所以页面看起来正常。CI 里也从来没有任何链接检查。

- tools/check_image_links.py:只认 markdown ![]() 与 <img src>,
  先剥离代码块以免把 `convert in.jpg out.jpg` 这类示例当成引用,
  并按 site_config 的 route 解析站点绝对路径。
- tools/known_broken_images.txt:当前仍存在的 121 处已知断链基线。
  CI 对不在基线中的断链报错,基线只许减不许增。
- .github/workflows/check_links.yml:push 与 PR 均运行。
- README:建议 --depth=1 浅克隆并解释原因——文档站的历史里保留了
  每张图的所有旧版本(约 1GB),而贡献者只需要当前版本。
This commit is contained in:
zepan
2026-10-07 01:01:33 +08:00
parent 73defb366b
commit 2821a421bc
4 changed files with 312 additions and 1 deletions

24
.github/workflows/check_links.yml vendored Normal file
View File

@@ -0,0 +1,24 @@
name: check_image_links
# Broken image links are easy to introduce and hard to spot: the published site
# keeps serving stale copies uploaded by earlier builds, so a page can look fine
# long after the image was moved or deleted from the repo. Check it here instead.
on:
push:
branches: [ main ]
pull_request:
workflow_dispatch:
jobs:
check-image-links:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Check that referenced images exist
run: python3 tools/check_image_links.py --list

View File

@@ -18,7 +18,7 @@ More contribute doc :
## Preview locally
```bash
git clone https://github.com/sipeed/sipeed_wiki.git
git clone --depth=1 https://github.com/sipeed/sipeed_wiki.git
cd sipeed_wiki
uv sync
uv run teedoc serve
@@ -27,6 +27,23 @@ uv run teedoc serve
`uv sync` creates/updates the `.venv` from [`pyproject.toml`](./pyproject.toml) and `uv.lock`, which already includes `teedoc` and all plugins used by this site (so `teedoc install` is not needed).
Install [uv](https://docs.astral.sh/uv/) first if you don't have it.
### Why `--depth=1`
This is a documentation site, so most of the repo is images. Git keeps every past revision
of every one of them forever, including large originals that have since been replaced by
compressed versions. A full clone therefore downloads roughly **1 GB of history** even
though you only ever need the current version of each file.
`--depth=1` fetches just the latest commit and skips all of that. Editing docs and opening
a pull request works exactly the same from a shallow clone. Use a full clone only when you
actually need the history (`git log`, `git blame`, `git bisect`) — you can always deepen a
shallow clone later with `git fetch --unshallow`.
When adding images, please keep them small (ideally under 300 KB, 500 KB max): anything
committed here stays in the history for good, so an oversized image costs every future
contributor bandwidth even after it is deleted. See [AGENTS.md](./AGENTS.md) for the full
image and authoring guidelines.
More build tool usage see [teedoc](http://github.com/teedoc/teedoc)
## For administrator

145
tools/check_image_links.py Normal file
View File

@@ -0,0 +1,145 @@
#!/usr/bin/env python3
"""Check that every image referenced by the docs actually exists.
Broken image links are easy to introduce (moving an assets/ folder, deleting a
file without touching the pages that use it) and hard to notice, because the
published site keeps serving stale copies that were uploaded by earlier builds.
This script catches them at CI time instead.
Only real image references are checked: markdown ``![alt](path)`` and HTML
``<img src=...>``. Fenced and inline code is stripped first, so example commands
like ``convert in.jpg out.jpg`` are not mistaken for links.
Usage:
python3 tools/check_image_links.py # fail on anything not in the baseline
python3 tools/check_image_links.py --list # print every broken link
python3 tools/check_image_links.py --update-baseline
"""
import argparse
import json
import os
import re
import sys
import urllib.parse
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
BASELINE = os.path.join(ROOT, "tools", "known_broken_images.txt")
SCAN_DIRS = ["docs", "news", "pages", "layout"]
SKIP_DIRS = {".git", "out", ".venv", "node_modules", "__pycache__"}
PAGE_EXTS = {".md", ".html"}
IMG_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".ico"}
FENCED = re.compile(r"```.*?```|~~~.*?~~~", re.S)
INLINE_CODE = re.compile(r"`[^`\n]*`")
MD_IMG = re.compile(r"!\[[^\]]*\]\(\s*<?([^)\s>]+)")
HTML_IMG = re.compile(r"<img\b[^>]*?\bsrc\s*=\s*[\"']([^\"']+)", re.I)
def load_routes():
"""Map URL prefixes to on-disk directories, as teedoc does when building."""
with open(os.path.join(ROOT, "site_config.json"), encoding="utf-8") as f:
cfg = json.load(f)
routes = {}
for group in ("docs", "pages", "assets"):
for url, path in (cfg.get("route", {}).get(group) or {}).items():
routes[url] = path
for url, targets in (cfg.get("translate", {}).get("docs") or {}).items():
for t in targets:
routes[t["url"]] = t["src"]
return dict(sorted(routes.items(), key=lambda kv: -len(kv[0])))
def resolve(ref, page_path, routes):
"""Return the on-disk path a reference points at, or None if it is external."""
ref = ref.strip().strip("'\"")
if ref.startswith(("http://", "https://", "//", "data:", "mailto:")):
return None
ref = urllib.parse.unquote(ref.split("#")[0].split("?")[0])
if not ref or os.path.splitext(ref)[1].lower() not in IMG_EXTS:
return None
if ref.startswith("/"):
for url, path in routes.items():
if ref.startswith(url):
return os.path.normpath(os.path.join(ROOT, path, ref[len(url):]))
return os.path.normpath(os.path.join(ROOT, ref.lstrip("/")))
return os.path.normpath(os.path.join(os.path.dirname(page_path), ref))
def scan():
routes = load_routes()
broken = []
for top in SCAN_DIRS:
for dirpath, dirnames, filenames in os.walk(os.path.join(ROOT, top)):
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
for name in filenames:
if os.path.splitext(name)[1].lower() not in PAGE_EXTS:
continue
page = os.path.join(dirpath, name)
try:
text = open(page, encoding="utf-8").read()
except (OSError, UnicodeDecodeError):
continue
text = INLINE_CODE.sub(" ", FENCED.sub(" ", text))
rel_page = os.path.relpath(page, ROOT)
for ref in set(MD_IMG.findall(text) + HTML_IMG.findall(text)):
if "{{" in ref or "{%" in ref: # jinja template expression
continue
target = resolve(ref, page, routes)
if target and not os.path.exists(target):
broken.append(f"{rel_page}\t{ref}")
return sorted(set(broken))
def read_baseline():
if not os.path.exists(BASELINE):
return set()
with open(BASELINE, encoding="utf-8") as f:
return {l.rstrip("\n") for l in f if l.strip() and not l.startswith("#")}
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--list", action="store_true", help="print all broken links")
ap.add_argument("--update-baseline", action="store_true",
help="record the current broken links as accepted")
args = ap.parse_args()
broken = scan()
if args.update_baseline:
with open(BASELINE, "w", encoding="utf-8") as f:
f.write("# Image links that are already broken and not yet fixed.\n"
"# CI fails on anything NOT listed here, so this file may shrink\n"
"# but must never grow. Regenerate with:\n"
"# python3 tools/check_image_links.py --update-baseline\n")
f.write("\n".join(broken) + "\n")
print(f"baseline updated: {len(broken)} known broken links")
return 0
baseline = read_baseline()
new = [b for b in broken if b not in baseline]
fixed = [b for b in baseline if b not in broken]
if args.list:
for b in broken:
page, ref = b.split("\t")
print(f"{page}: {ref}")
print(f"{len(broken)} broken image links "
f"({len(baseline)} known, {len(new)} new, {len(fixed)} fixed since baseline)")
if fixed:
print(f"\n{len(fixed)} link(s) in the baseline are fixed - please run "
f"`python3 tools/check_image_links.py --update-baseline` and commit.")
if new:
print(f"\nNew broken image links introduced by this change:\n")
for b in new:
page, ref = b.split("\t")
print(f" {page}\n -> {ref}")
print("\nFix the path, add the missing image, or remove the reference.")
return 1
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,125 @@
# Image links that are already broken and not yet fixed.
# CI fails on anything NOT listed here, so this file may shrink
# but must never grow. Regenerate with:
# python3 tools/check_image_links.py --update-baseline
docs/hardware/en/kvm/NanoKVM/user_guide.md ./../../../assets/NanoKVM/guide/atx_2024.png
docs/hardware/en/kvm/NanoKVM_Pro/extended.md ./../../../assets/NanoKVM/pro/extended/hdmi.jpg
docs/hardware/en/kvm/NanoKVM_Pro/ledstrip.md ../../../assets/NanoKVM/pro/ledstrip/ledstrip_show.png
docs/hardware/en/lichee/K1/lc3a/lc3a.md ./assets/lc3a/lc3a_architecture.png
docs/hardware/en/lichee/K1/lc3a/lc3a.md ./assets/lc3a/lc3a_box.png
docs/hardware/en/lichee/K1/lc3a/lc3a.md ./assets/lc3a/lc3a_install_goldfinger.png
docs/hardware/en/lichee/K1/lc3a/lc3a.md ./assets/lc3a/lc3a_install_install_lm3a.png
docs/hardware/en/lichee/K1/lc3a/lc3a.md ./assets/lc3a/lc3a_install_slot.jpeg
docs/hardware/en/lichee/K1/lc3a/lc3a.md ./assets/lc3a/lc3a_power_atx20_cable.png
docs/hardware/en/lichee/K1/lc3a/lc3a.md ./assets/lc3a/lc3a_power_jumpwire.png
docs/hardware/en/lichee/K1/lc3a/lc3a.md ./assets/lc3a/lc3a_top.png
docs/hardware/en/lichee/K1/lpi3a/3_burn_image.md ./assets/images/deepin.jpg
docs/hardware/en/lichee/K1/lpi3a/3_burn_image.md ./assets/images/fedora.png
docs/hardware/en/lichee/K1/lpi3a/3_burn_image.md ./assets/images/irradium.png
docs/hardware/en/lichee/K1/lpi3a/3_burn_image.md ./assets/images/openkylin.png
docs/hardware/en/lichee/RV_Nano/2_unbox.md ./../assets/RV_Nano/unbox/1.jpg
docs/hardware/en/lichee/th1520/lbook4a/lbook4a.md ./assets/lbook/lbook.jpg
docs/hardware/en/lichee/th1520/lpi4a/8_application.md /images/kubernetes.png
docs/hardware/en/logic_analyzer/slogic16u3/Software_User_Guide.md ./assets/Screenshots/SLogic_Connect_1.png
docs/hardware/en/maix/modules_spmod/spmod_bt.md ../../assets/spmod/spmod_bt/back.jpg
docs/hardware/en/maix/modules_spmod/spmod_bt.md ../../assets/spmod/spmod_bt/connection.png
docs/hardware/en/maix/modules_spmod/spmod_eink.md ../../assets/spmod/spmod_eink/connection.png
docs/hardware/en/maix/modules_spmod/spmod_eink.md ../../assets/spmod/spmod_eink/sp_eink_c.png
docs/hardware/en/maix/modules_spmod/spmod_lcd1.14.md ../../assets/spmod/spmod_lcd1.14//connection.png
docs/hardware/en/maix/modules_spmod/spmod_lcd1.14.md ../../assets/spmod/spmod_lcd1.14/back.png
docs/hardware/en/maix/modules_spmod/spmod_lora.md ../../assets/spmod/spmod_lora//connection.png
docs/hardware/en/maix/modules_spmod/spmod_lora.md ../../assets/spmod/spmod_lora/back.png
docs/hardware/en/maix/modules_spmod/spmod_rfid.md ../../assets/spmod/spmod_rfid/back.png
docs/hardware/en/maix/modules_spmod/spmod_rfid.md ../../assets/spmod/spmod_rfid/connection.png
docs/hardware/en/maix/modules_spmod/spmod_rfid.md ../../assets/spmod/spmod_rfid/sp_rfid.png
docs/hardware/en/maix/modules_spmod/spmod_tof.md ../../assets/spmod/spmod_tof/back.png
docs/hardware/en/maix/modules_spmod/spmod_tof.md ../../assets/spmod/spmod_tof/connection.png
docs/hardware/en/maix/modules_spmod/spmod_tof.md ../../assets/spmod/spmod_tof/log_c.png
docs/hardware/en/maix/modules_spmod/spmod_weather.md ../../assets/spmod/spmod_weather/back.png
docs/hardware/en/maix/modules_spmod/spmod_weather.md ../../assets/spmod/spmod_weather/connection.png
docs/hardware/en/maix/modules_spmod/spmod_weather.md ../../assets/spmod/spmod_weather/demo.gif
docs/hardware/en/maix/modules_spmod/spmod_weather.md ../../assets/spmod/spmod_weather/log_c.png
docs/hardware/en/maixcam/README.md ../../assets/maixcam/maixcam.jpeg
docs/hardware/en/maixface/mf_ml_module/mf0_ml_module.md ../assets/mf_module/mf1_bottom_v2(nand).png
docs/hardware/en/maixface/mf_ml_module/mf1_ml_module.md ../../assests/front.png
docs/hardware/en/maixface/mf_ml_module/mf1_ml_module.md assests/bottom.jpg
docs/hardware/en/maixface/mf_ml_module/mf_update_firmwave.md ../../assets/mf_module/mf1/mf_dual_camera_2.png
docs/hardware/en/modules_spmod/spmod_template.md ../../assets/spmod/sipeed_spmod_xxx.jpg
docs/hardware/en/tang/tang-mega-138k/mega-138k-pro.md ./assets/partno_138K_Pro.png
docs/hardware/maixface/en/core_modules/k210_core_modules.md ../../assets/mf_module/m1_m1w/sipeed_m1_module.png
docs/hardware/maixface/en/core_modules/k210_core_modules.md ../../assets/mf_module/m1_m1w/sipeed_m1w_module.png
docs/hardware/maixface/en/core_modules/k210_core_modules.md ../../assets/mf_module/m1n/sipeed_m1n_module.png
docs/hardware/maixface/en/core_modules/k210_core_modules.md ../../assets/mf_module/mf0_mf0dock/sipeed_mf0_module.png
docs/hardware/maixface/en/mf_ml_module/mf0_ml_module.md ../../assets/mf_module/mf0_mf0dock/mf0_buttom.png
docs/hardware/maixface/en/mf_ml_module/mf0_ml_module.md ../../assets/mf_module/mf0_mf0dock/mf0_dock_flash.png
docs/hardware/maixface/en/mf_ml_module/mf0_ml_module.md ../../assets/mf_module/mf0_mf0dock/mf0_flash.png
docs/hardware/maixface/en/mf_ml_module/mf0_ml_module.md ../../assets/mf_module/mf0_mf0dock/mf0_pin_map.png
docs/hardware/maixface/en/mf_ml_module/mf0_ml_module.md ../../assets/mf_module/mf0_mf0dock/mf0_upgrade_firmwave.png
docs/hardware/maixface/en/mf_ml_module/mf0_ml_module.md ../assets/mf_module/mf1_bottom_v2(nand).png
docs/hardware/maixface/en/mf_ml_module/mf1_ml_module.md ../../assests/front.png
docs/hardware/maixface/en/mf_ml_module/mf1_ml_module.md ../../assets/mf_module/mf1/mf1_pin_map.png
docs/hardware/maixface/en/mf_ml_module/mf1_ml_module.md assests/bottom.jpg
docs/hardware/maixface/en/mf_ml_module/mf_update_firmwave.md ../../assets/mf_module/mf1/mf1_view.png
docs/hardware/maixface/en/mf_ml_module/mf_update_firmwave.md ../../assets/mf_module/mf1/mf_dual_camera_1.jpg
docs/hardware/maixface/en/mf_ml_module/mf_update_firmwave.md ../../assets/mf_module/mf1/mf_dual_camera_2.png
docs/hardware/maixface/zh/core_modules/k210_core_modules.md ../../assets/mf_module/m1_m1w/K210_boot_reference.png
docs/hardware/maixface/zh/core_modules/k210_core_modules.md ../../assets/mf_module/m1_m1w/sipeed_m1_module.png
docs/hardware/maixface/zh/core_modules/k210_core_modules.md ../../assets/mf_module/m1_m1w/sipeed_m1w_module.png
docs/hardware/maixface/zh/core_modules/k210_core_modules.md ../../assets/mf_module/m1n/sipeed_m1n_module.png
docs/hardware/maixface/zh/core_modules/k210_core_modules.md ../../assets/mf_module/mf0_mf0dock/sipeed_mf0_module.png
docs/hardware/maixface/zh/mf_ml_module/mf0_ml_module.md ../../assets/mf_module/mf0_mf0dock/mf0_buttom.png
docs/hardware/maixface/zh/mf_ml_module/mf0_ml_module.md ../../assets/mf_module/mf0_mf0dock/mf0_dock_flash.png
docs/hardware/maixface/zh/mf_ml_module/mf0_ml_module.md ../../assets/mf_module/mf0_mf0dock/mf0_flash.png
docs/hardware/maixface/zh/mf_ml_module/mf0_ml_module.md ../../assets/mf_module/mf0_mf0dock/mf0_pin_map.png
docs/hardware/maixface/zh/mf_ml_module/mf0_ml_module.md ../../assets/mf_module/mf0_mf0dock/mf0_upgrade_firmwave.png
docs/hardware/maixface/zh/mf_ml_module/mf0_ml_module.md ../assets/mf_module/mf1_bottom_v2(nand).png
docs/hardware/maixface/zh/mf_ml_module/mf1_ml_module.md ../../assests/front.png
docs/hardware/maixface/zh/mf_ml_module/mf1_ml_module.md ../../assets/mf_module/mf1/mf1_pin_map.png
docs/hardware/maixface/zh/mf_ml_module/mf1_ml_module.md assests/bottom.jpg
docs/hardware/maixface/zh/mf_ml_module/mf_precautions.md ../../assets/other/mf_precautions.png
docs/hardware/maixface/zh/mf_ml_module/mf_update_firmwave.md ../../assets/mf_module/mf1/mf1_view.png
docs/hardware/maixface/zh/mf_ml_module/mf_update_firmwave.md ../../assets/mf_module/mf1/mf_dual_camera_1.jpg
docs/hardware/maixface/zh/mf_ml_module/mf_update_firmwave.md ../../assets/mf_module/mf1/mf_dual_camera_2.png
docs/hardware/zh/lichee/th1520/lbook4a/lbook4a.md ./assets/lbook/lbook.jpg
docs/hardware/zh/logic_analyzer/slogic16u3/Software_User_Guide.md ../../../en/logic_analyzer/slogic16u3/assets/Screenshots/SLogic_Connect_1.png
docs/hardware/zh/maixII/M2/usage.md /news/MaixPy3/v831_usage/assest/adb.jpg
docs/hardware/zh/maixII/M2/usage.md /news/MaixPy3/v831_usage/assest/delete.jpg
docs/hardware/zh/maixcam/README.md ../../assets/maixcam/maixcam.jpeg
docs/hardware/zh/maixface/mf_ml_module/mf0_ml_module.md ../assets/mf_module/mf1_bottom_v2(nand).png
docs/hardware/zh/maixface/mf_ml_module/mf0_ml_module.md ./../zh/maixface/assets/mf_module/mf0_mf0dock/mf0_dock_flash.png
docs/hardware/zh/maixface/mf_ml_module/mf1_ml_module.md ./../assests/front.png
docs/hardware/zh/maixface/mf_ml_module/mf1_ml_module.md assests/bottom.jpg
docs/hardware/zh/modules_spmod/spmod_template.md ../../assets/spmod/sipeed_spmod_xxx.jpg
docs/hardware/zh/tang/Tang-Nano-9K/examples/LCD.md ./../../Tang-Nano-1K/Nano_1K_examples/led/assets/add_ip_file_in_project.png
docs/hardware/zh/tang/tang-PMOD/FPGA_PMOD.md ./assets/PMOD_HUb75E_bot.jpg
docs/soft/Lichee/zh/Lichee-RV/contribution/Dual_screen_display/environment.md ./assets/enviroument.png
docs/soft/Lichee/zh/Lichee-RV/get_started/user.md ./../assets/RV/wifi-1.jpg
docs/soft/Lichee/zh/Lichee-RV/get_started/user.md ./../assets/RV/wifi-2.jpg
docs/soft/Lichee/zh/MaixII/V83X/contribution/v83x_sdk_compile/V83x_sdk_kernel_compile.md ./assets/11.png
docs/soft/Lichee/zh/Zero-Doc/System_Development/SPI_falsh_doc4.md ./../static/System_Development/uboot_conf_9.gif
docs/soft/Tang/en/Gowin/Tang_nano/README.md ../assets/sipeed_longan_logo.jpg
docs/soft/Tang/zh/Tang-Common-Doc/examples/4K_led.md ./../../../Nano-4K/4K-led-2.png
docs/soft/maixpy/en/course/others/maixui.md ./image/app_main.gif
docs/soft/maixpy/en/develop_kit_board/develop_kit_board.md ../../assets/dk_board/maix_bit/BiT.png
docs/soft/maixpy/en/develop_kit_board/develop_kit_board.md ../../assets/dk_board/maix_cube/maixcube_2020-06-13_06-31-29.png
docs/soft/maixpy/en/develop_kit_board/develop_kit_board.md ../../assets/dk_board/maix_dock/Dan_Dock.png
docs/soft/maixpy/en/develop_kit_board/develop_kit_board.md ../../assets/dk_board/maix_duino/maixduino_0.png
docs/soft/maixpy/en/develop_kit_board/develop_kit_board.md ../../assets/dk_board/maix_go/Go.jpg
docs/soft/maixpy/en/get_started/knowledge_git_github.md /assets/other/github_star.jpg
docs/soft/maixpy/en/get_started/upgrade_esp8285_firmware.md ../../assets/hardware/maix_dock/sipeed_maix_dock_m1w_2.png
docs/soft/maixpy/en/get_started/upgrade_maixpy_firmware.md ../../assets/kflash_gui_screenshot_download.png
docs/soft/maixpy/zh/course/others/maixui.md ./image/app_main.gif
docs/soft/maixpy/zh/develop_kit_board/develop_kit_board.md ../../assets/dk_board/maix_bit/BiT.png
docs/soft/maixpy/zh/develop_kit_board/develop_kit_board.md ../../assets/dk_board/maix_cube/maixcube_2020-06-13_06-31-29.png
docs/soft/maixpy/zh/develop_kit_board/develop_kit_board.md ../../assets/dk_board/maix_dock/Dan_Dock.png
docs/soft/maixpy/zh/develop_kit_board/develop_kit_board.md ../../assets/dk_board/maix_duino/maixduino_0.png
docs/soft/maixpy/zh/develop_kit_board/develop_kit_board.md ../../assets/dk_board/maix_go/Go.jpg
docs/soft/maixpy3/zh/api/maix/gpio.md ./asserts/v831_gpio.png
docs/soft/maixpy3/zh/api/maix/gpio.md ./asserts/v831_pin_maps.png
docs/soft/maixpy3/zh/usage/hardware/back/GPIO_1.md ./../asserts/M2Dock_pin.png
docs/soft/maixpy3/zh/usage/hardware/back/I2C_1.md ./../asserts/M2Dock_pin.png
docs/soft/maixpy3/zh/usage/hardware/back/PWM_1.md ./../asserts/pwm.gif
docs/soft/maixpy3/zh/usage/hardware/back/PWM_1.md ./../asserts/pwm.jpg
news/MaixPy3/v831_usage/v831_usage.md /news/MaixPy3/v831_usage/assest/usb.jpg
news/others/831_ssh/831_ssh.md ./assets/succeed_login.png
pages/index/zh/threshold.html demo_color.jpg