diff --git a/.github/workflows/content-check.yml b/.github/workflows/content-check.yml index 1e85829..2cad0a7 100644 --- a/.github/workflows/content-check.yml +++ b/.github/workflows/content-check.yml @@ -35,5 +35,12 @@ jobs: cache: "pnpm" - run: pnpm install --frozen-lockfile + # Non-blocking image migration + lint (visibility only) + - name: Migrate images next to MDX (check only) + run: pnpm migrate:images || echo "[warn] migrate:images failed (non-blocking)" + + - name: Lint image references (non-blocking) + run: pnpm lint:images || echo "[warn] image lint found issues (non-blocking)" + # Build the site to validate MDX and docs using Fumadocs - run: pnpm build diff --git a/.husky/pre-commit b/.husky/pre-commit index 5ee7abd..a2aca1f 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1,11 @@ +# 1) 将 /images/* 文章图片就近复制并更新引用 +pnpm migrate:images || exit 1 + +# 将迁移后的变更加入暂存,确保本次提交包含更新 +git add -A + +# 2) 校验图片路径与命名(不合规则阻止提交) +pnpm lint:images || exit 1 + +# 3) 其余按 lint-staged 处理(如 Prettier) pnpm exec lint-staged diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9f54689..2182e9c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -30,7 +30,7 @@ touch docs/computer-science/data-structures/new-topic/index.mdx 使用 Markdown/MDX 编写文章: -````mdx +```mdx --- title: "文章标题" description: "文章简短描述" @@ -51,20 +51,7 @@ tags: 更多内容... ## 代码示例 - -```javascript -// 你的代码 -function example() { - return "Hello World!"; -} ``` -```` - -## 总结 - -文章总结... - -```` ### 步骤4:测试修改 @@ -72,7 +59,8 @@ function example() { ```bash pnpm build -```` +pnpm migrate:images # 迁移图片脚本 +``` 此命令将: @@ -141,6 +129,17 @@ npm dev ## 📚 文档规范 所有文档放在 `docs/` 目录。 +图片需要放在 被引用的文档的同名`assets`目录下(正常情况下您不应该关心这个, 该项目有自动脚本来移动图片), 例如: +docxA 引用了 imgA 图片, 那么他们的文档结构应该是 `docxA.assets/imgA`: + +```md +docsA.mdx +docsA.assets/ +imgA +``` + +![img](public/readme_docs_structure.png) + 每个文档都需要一个 Frontmatter,例如: ```md diff --git a/README.md b/README.md index d6fe8c0..57e56be 100644 --- a/README.md +++ b/README.md @@ -90,10 +90,17 @@ pnpm dev pnpm dev # 启动开发服务器 pnpm build # 构建生产版本 pnpm start # 启动生产服务器 +pnpm postinstall +pnpm lint:images # 检查图片符合规则 +pnpm migrate:images # 迁移图片 +``` -# 内容 +## 图片管理规范(简要) +自动化脚本会移动您引用的图片到 MDX 同目录下, 遵循以下规则: -# 导出 -pnpm export # 导出静态站点到 /out 目录 -``` +- 存放:与 MDX 同目录的 `./.assets/` 中。 + - 例:`foo.mdx` → `./foo.assets/.png`;`index.mdx` → `./index.assets/.png`。 +- 引用:相对路径 `![](./.assets/.png)`。 +- 自动化:提交时自动迁移并改引用;图片 Lint 只提示不拦截提交。 +- 共享:站点级用 `/images/site/*`、组件演示用 `/images/components//*`;多文档共用的图片可保留 `/images/...`。 diff --git a/public/images/word/word-img-03.png b/app/docs/ai/multimodal/llava/index.assets/word-img-03.png similarity index 100% rename from public/images/word/word-img-03.png rename to app/docs/ai/multimodal/llava/index.assets/word-img-03.png diff --git a/public/images/word/word-img-04.png b/app/docs/ai/multimodal/llava/index.assets/word-img-04.png similarity index 100% rename from public/images/word/word-img-04.png rename to app/docs/ai/multimodal/llava/index.assets/word-img-04.png diff --git a/public/images/word/word-img-05.png b/app/docs/ai/multimodal/llava/index.assets/word-img-05.png similarity index 100% rename from public/images/word/word-img-05.png rename to app/docs/ai/multimodal/llava/index.assets/word-img-05.png diff --git a/app/docs/ai/multimodal/llava/index.mdx b/app/docs/ai/multimodal/llava/index.mdx index b89da28..a3e76ff 100644 --- a/app/docs/ai/multimodal/llava/index.mdx +++ b/app/docs/ai/multimodal/llava/index.mdx @@ -1,4 +1,4 @@ ---- +--- title: "LLaVA" description: "LLaVA多模态大模型框架:架构解析、CLIP基础、论文精读、复现实践" date: "2025-01-27" @@ -12,7 +12,7 @@ tags: LLaVA (Large Language and Vision Assistant) 是多模态大模型的开创性框架,开启了视觉指令调优的新范式。 -![](/images/word/word-img-03.png) +![](index.assets/word-img-03.png) ## 核心架构 @@ -22,9 +22,9 @@ LLaVA (Large Language and Vision Assistant) 是多模态大模型的开创性框 ViT视觉编码器 → 投影层跨模态对齐 → LLM语言生成 ``` -![](/images/word/word-img-04.png) +![](index.assets/word-img-04.png) -![](/images/word/word-img-05.png) +![](index.assets/word-img-05.png) ### 技术特点 diff --git a/package.json b/package.json index 5d7f298..51de677 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,9 @@ "build": "next build", "start": "next start", "postinstall": "fumadocs-mdx", - "prepare": "husky" + "prepare": "husky", + "lint:images": "node scripts/check-images.mjs", + "migrate:images": "node scripts/move-doc-images.mjs" }, "dependencies": { "@types/mdx": "^2.0.13", diff --git a/public/readme_docs_structure.png b/public/readme_docs_structure.png new file mode 100644 index 0000000..9a21b4f Binary files /dev/null and b/public/readme_docs_structure.png differ diff --git a/scripts/check-images.mjs b/scripts/check-images.mjs new file mode 100644 index 0000000..e3b74e1 --- /dev/null +++ b/scripts/check-images.mjs @@ -0,0 +1,200 @@ +#!/usr/bin/env node +/** + * MDX 图片路径校验脚本 + * + * 功能 + * - 扫描 `app/docs/??/?.mdx`(含 .md) + * - 识别 Markdown `![]()` 与内联 `` 的图片引用 + * - 强制使用“就近图片”:推荐相对路径(如 `./images/…`) + * - 仅对站点级共享资源允许绝对路径:`/images/site/*`、`/images/components/*` + * - 校验图片文件是否存在、文件名是否符合 kebab-case + * + * 目的 + * - 图片与文章同目录,便于维护与迁移 + * - 避免全局命名冲突,降低重构成本 + * + * 使用 + * - 包脚本:`pnpm lint:images` + * - 直接运行:`node scripts/check-images.mjs` + * + * 退出码 + * - 0:通过;1:存在问题(便于接入 CI) + */ + +import fs from "fs"; +import path from "path"; + +const ROOT = process.cwd(); +const DOCS_DIR = path.join(ROOT, "app", "docs"); +// 允许的绝对路径前缀(站点级 & 组件演示级别) +const ALLOWED_ABSOLUTE_PREFIXES = ["/images/site/", "/images/components/"]; + +// 图片文件扩展名 +const IMAGE_FILE_EXTS = new Set([ + ".png", + ".jpg", + ".jpeg", + ".gif", + ".webp", + ".svg", +]); +const exts = new Set([".mdx", ".md"]); + +/** Recursively list files */ +function* walk(dir) { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const e of entries) { + const p = path.join(dir, e.name); + if (e.isDirectory()) { + yield* walk(p); + } else { + yield p; + } + } +} + +/** + * 将文件路径转换为路由路径 + */ +function toRoutePath(file) { + const rel = path.relative(DOCS_DIR, file).split(path.sep).join("/"); + const base = path.basename(rel); + const dir = path.dirname(rel); + if (base.toLowerCase() === "index.mdx") return dir === "." ? "" : dir; + const name = base.replace(/\.[^.]+$/, ""); + return dir === "." ? name : `${dir}/${name}`; +} + +/** + * 判断文件名(含扩展名)是否为 kebab-case(仅校验主名,不含后缀) + * 示例:training-loop.png、fig-01-architecture.webp 为合格 + */ +function isKebabCase(name) { + // allow dot for extension only + const base = name.replace(/\.[^.]+$/, ""); + return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(base); +} + +/** + * 校验单个 MD(X) 文件中的图片使用: + * - 非站点级的绝对路径 `/images/*` 会被提示应改为相对路径 + * - 相对路径需位于同一文档目录(避免越级引用) + * - 检查文件是否存在与命名是否符合 kebab-case + */ +/** + * 构建全局引用表:统计所有文档对非站点级 `/images/...` 的引用次数 + */ +function buildRefs() { + const reMdx = /!\[[^\]]*\]\(([^)]+)\)/g; + const reHtml = /]*src=["']([^"']+)["'][^>]*>/gi; + /** @type {Map>} */ + const refs = new Map(); + for (const f of walk(DOCS_DIR)) { + if (!exts.has(path.extname(f))) continue; + const s = fs.readFileSync(f, "utf8"); + const urls = new Set(); + for (const m of s.matchAll(reMdx)) urls.add(m[1]); + for (const m of s.matchAll(reHtml)) urls.add(m[1]); + for (const url of urls) { + const u = url.replace(/\\/g, "/"); + if (!u.startsWith("/images/")) continue; + if (ALLOWED_ABSOLUTE_PREFIXES.some((p) => u.startsWith(p))) continue; + if (!refs.has(u)) refs.set(u, new Set()); + refs.get(u).add(f); + } + } + return refs; +} + +function checkFile(file, refs) { + const content = fs.readFileSync(file, "utf8"); + const routePath = toRoutePath(file); + const baseDir = path.dirname(file); + const baseName = path.basename(file, path.extname(file)); + const expectedRelPrefix = `./${baseName}.assets/`; + // Markdown 图片语法:![alt](src) + const re = /!\[[^\]]*\]\(([^)]+)\)/g; + // HTML 图片语法: + const inlineRe = /]*src=["']([^"']+)["'][^>]*>/gi; + const problems = []; + + function checkUrl(url, loc) { + const urlNorm = url.replace(/\\/g, "/"); + if (/^https?:\/\//i.test(urlNorm)) return; // 外链忽略 + // 绝对路径 + if (urlNorm.startsWith("/images/")) { + const okAbs = + ALLOWED_ABSOLUTE_PREFIXES.some((p) => urlNorm.startsWith(p)) || + (refs.get(urlNorm)?.size || 0) > 1; + if (!okAbs) { + problems.push( + `${loc}: prefer co-located images; use ${expectedRelPrefix} (avoid ${urlNorm})`, + ); + } + const fname = urlNorm.split("/").pop() || ""; + if (!isKebabCase(fname)) + problems.push(`${loc}: filename not kebab-case -> ${fname}`); + return; + } + // 相对路径 + if (urlNorm.startsWith("./") || urlNorm.startsWith("../")) { + const abs = path.resolve(baseDir, urlNorm); + const relToDocs = path.relative(DOCS_DIR, abs); + if (relToDocs.startsWith("..")) { + problems.push( + `${loc}: image must live within the same doc folder (got ${urlNorm})`, + ); + } + const ext = path.extname(abs).toLowerCase(); + if (!IMAGE_FILE_EXTS.has(ext)) return; // 非图片文件忽略 + if (!fs.existsSync(abs)) { + problems.push(`${loc}: image file not found -> ${urlNorm}`); + } + const fname = path.basename(abs); + if (!isKebabCase(fname)) + problems.push(`${loc}: filename not kebab-case -> ${fname}`); + return; + } + // 其它形式(如 data: 或 import)忽略 + } + + // scan markdown image syntax + for (const m of content.matchAll(re)) { + checkUrl(m[1], "mdx"); + } + // scan inline + for (const m of content.matchAll(inlineRe)) { + checkUrl(m[1], "html"); + } + + return problems; +} + +function main() { + if (!fs.existsSync(DOCS_DIR)) { + console.error(`Docs dir not found: ${DOCS_DIR}`); + process.exit(1); + } + let total = 0; + let bad = 0; + const refs = buildRefs(); + for (const f of walk(DOCS_DIR)) { + if (!exts.has(path.extname(f))) continue; + total++; + const probs = checkFile(f, refs); + if (probs.length) { + bad++; + const rel = path.relative(ROOT, f).split(path.sep).join("/"); + console.log(`\n${rel}`); + for (const p of probs) console.log(` - ${p}`); + } + } + if (bad) { + console.log(`\nFound ${bad}/${total} files with image issues.`); + process.exit(1); + } else { + console.log(`Checked ${total} MDX files: no issues.`); + } +} + +main(); diff --git a/scripts/move-doc-images.mjs b/scripts/move-doc-images.mjs new file mode 100644 index 0000000..1c9fa0d --- /dev/null +++ b/scripts/move-doc-images.mjs @@ -0,0 +1,242 @@ +#!/usr/bin/env node +/** + * MDX 图片就近迁移脚本(中文注释) + * + * 目标 + * - 扫描 `app/docs/??/?.mdx`(含 .md)里的图片引用 + * - 对于以 `/images/...` 绝对路径引用且仅被“单一文档”使用的图片:移动到对应 MDX 同目录下的 `images/` 子目录 + * - 同时将文中的绝对路径替换为相对路径 `./images/<文件名>`(站点级资源除外) + * + * 为什么需要 + * - 图片与文章就近存放,便于迁移、重命名、归档与协作 + * - 避免公共目录下命名冲突与难以追踪的引用关系 + * + * 使用方式 + * - 包管理脚本:`pnpm migrate:images` + * - 直接运行:`node scripts/move-doc-images.mjs` + * + * 规则 + * - 保留并忽略站点级绝对路径:`/images/site/*`、`/images/components/*` + * - 共享图片(被多个文档引用)将保留在 public 中,并保持绝对路径;防止重复与不必要的拷贝 + * - 单一文档引用的图片采用“移动”(rename 或 copy+unlink),避免产生重复副本 + **/ + +import fs from "fs"; +import path from "path"; +import crypto from "crypto"; + +// 仓库根目录、文档目录与 public 目录 +const ROOT = process.cwd(); +const DOCS_DIR = path.join(ROOT, "app", "docs"); +const PUBLIC_DIR = path.join(ROOT, "public"); + +// 排除不迁移的绝对路径前缀(站点级 & 组件演示级别) +const EXCLUDE_PREFIXES = ["/images/site/", "/images/components/"]; + +// 需要处理的文档扩展名 +const exts = new Set([".mdx", ".md"]); + +/** 递归遍历目录,产出文件路径 */ +function* walk(dir) { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const e of entries) { + const p = path.join(dir, e.name); + if (e.isDirectory()) yield* walk(p); + else yield p; + } +} + +/** 确保目录存在(多层级) */ +function ensureDir(p) { + fs.mkdirSync(p, { recursive: true }); +} + +/** 读取文件内容为字符串 */ +function read(file) { + return fs.readFileSync(file, "utf8"); +} + +/** 简单计算文件 SHA1,用于判等(可避免重复移动时产生副本) */ +function sha1(p) { + const h = crypto.createHash("sha1"); + h.update(fs.readFileSync(p)); + return h.digest("hex"); +} + +/** + * 构建引用表:统计所有文档对 `/images/...`(排除站点级前缀)的引用次数 + */ +function buildRefs() { + const mdxImg = /!\[[^\]]*\]\(([^)]+)\)/g; + const htmlImg = /]*src=["']([^"']+)["'][^>]*>/gi; + /** @type {Map>} url -> set of files */ + const refs = new Map(); + for (const f of walk(DOCS_DIR)) { + if (!exts.has(path.extname(f))) continue; + const content = read(f); + const urls = new Set(); + for (const m of content.matchAll(mdxImg)) urls.add(m[1]); + for (const m of content.matchAll(htmlImg)) urls.add(m[1]); + for (const url of urls) { + if (!url.startsWith("/images/")) continue; + if (EXCLUDE_PREFIXES.some((p) => url.startsWith(p))) continue; + if (!refs.has(url)) refs.set(url, new Set()); + refs.get(url).add(f); + } + } + return refs; +} + +/** + * 处理单个 MD(X) 文件: + * - 抓取 Markdown 与 HTML 中的图片地址 + * - 对“仅被本文件引用”的 `/images/...` 图片执行移动并替换为相对路径 + */ +function moveForFile(file, refs) { + const raw = fs.readFileSync(file, "utf8"); + let content = raw; + // Markdown 图片语法:![alt](src) + const mdxImg = /!\[[^\]]*\]\(([^)]+)\)/g; + // HTML 图片语法: + const htmlImg = /]*src=["']([^"']+)["'][^>]*>/gi; + const urls = new Set(); + for (const m of content.matchAll(mdxImg)) urls.add(m[1]); + for (const m of content.matchAll(htmlImg)) urls.add(m[1]); + + if (urls.size === 0) return { moved: 0, updated: false }; + let moved = 0; + const dir = path.dirname(file); + const baseName = path.basename(file, path.extname(file)); + const destDir = path.join(dir, `${baseName}.assets`); + + for (const url of urls) { + // 仅处理以 /images/ 开头的绝对路径 + if (!url.startsWith("/images/")) continue; + // 站点级与组件级图片跳过(保持绝对路径) + if (EXCLUDE_PREFIXES.some((p) => url.startsWith(p))) continue; + + // 若该图片被多个文档引用,则视为“共享图片”,保留绝对路径 + const consumers = refs.get(url); + if (consumers && consumers.size > 1) { + continue; + } + + // 计算 public 下的源文件路径 + const relFromPublic = url.replace(/^\//, ""); + const src = path.join(PUBLIC_DIR, relFromPublic); + if (!fs.existsSync(src)) { + console.warn( + `Skip (not found): ${src} (from ${url}) in ${path.relative(ROOT, file)}`, + ); + continue; + } + + // 移动到文章同目录的 images 子目录 + const base = path.basename(src); + ensureDir(destDir); + const dest = path.join(destDir, base); + if (fs.existsSync(dest)) { + // 若已存在同名文件,尝试比较内容,若相同则删除源文件以避免重复 + try { + if (sha1(src) === sha1(dest)) { + fs.unlinkSync(src); + moved++; + } else { + // 内容不同则保留源文件并提示人工处理 + console.warn( + `Conflict: ${path.relative(ROOT, dest)} already exists with different content.`, + ); + continue; + } + } catch (e) { + console.warn(`Compare failed for ${src} vs ${dest}: ${e.message}`); + continue; + } + } else { + // 优先使用 rename,提高效率;跨卷失败则回退为 copy+unlink + try { + fs.renameSync(src, dest); + } catch (e) { + try { + fs.copyFileSync(src, dest); + fs.unlinkSync(src); + } catch (e2) { + console.warn(`Move failed for ${src} -> ${dest}: ${e2.message}`); + continue; + } + } + moved++; + } + + // 将文中的绝对路径替换为相对路径 ./images/<文件名> + const rel = `./${baseName}.assets/${base}`; + // 转义正则中的特殊字符,确保全量替换 + const escaped = url.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const re = new RegExp(escaped, "g"); + content = content.replace(re, rel); + } + + // 额外处理:将历史相对路径 ./images/* 迁移至 ./.assets/* 并更新引用 + for (const url of urls) { + if (!url.startsWith("./images/")) continue; + const absSrc = path.resolve(dir, url); + if (!fs.existsSync(absSrc)) continue; + const base = path.basename(absSrc); + ensureDir(destDir); + const dest = path.join(destDir, base); + if (fs.existsSync(dest)) { + try { + if (sha1(absSrc) === sha1(dest)) { + fs.unlinkSync(absSrc); + } else { + console.warn( + `Conflict: ${path.relative(ROOT, dest)} already exists with different content.`, + ); + continue; + } + } catch (e) { + console.warn(`Compare failed for ${absSrc} vs ${dest}: ${e.message}`); + continue; + } + } else { + try { + fs.renameSync(absSrc, dest); + } catch (e) { + try { + fs.copyFileSync(absSrc, dest); + fs.unlinkSync(absSrc); + } catch (e2) { + console.warn(`Move failed for ${absSrc} -> ${dest}: ${e2.message}`); + continue; + } + } + } + moved++; + const newRel = `./${baseName}.assets/${base}`; + const escapedRel = url.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const reRel = new RegExp(escapedRel, "g"); + content = content.replace(reRel, newRel); + } + + if (content !== raw) fs.writeFileSync(file, content); + return { moved, updated: content !== raw }; +} + +/** 主流程:遍历所有 MDX,累计迁移数量并输出统计 */ +function main() { + let totalFiles = 0; + let totalMoved = 0; + // 第一步:构建全局引用表,识别共享图片 + const refs = buildRefs(); + for (const f of walk(DOCS_DIR)) { + if (!exts.has(path.extname(f))) continue; + totalFiles++; + const { moved } = moveForFile(f, refs); + totalMoved += moved; + } + console.log( + `已处理 ${totalFiles} 个文档,复制 ${totalMoved} 张图片到就近目录。`, + ); +} + +main();