Skip to content

Update manual_lint.js:重构校验脚本为模块化结构,提升可读性与可维护性;使用 path.resolve 统一管理 glob 路径,路径处理更安全。 #1587

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
May 13, 2025
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
314 changes: 176 additions & 138 deletions .github/manual_lint.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,158 +4,196 @@ const fs = require("fs").promises;
const path = require('path');

const MAX_FILE_SIZE = 1024 * 1024; // 1MB
// glob 模式,定位菜谱 Markdown 文件和所有文件
const DISHES_GLOB = path.resolve(__dirname, '../../dishes/**/*.md');
const ALL_FILES_GLOB = path.resolve(__dirname, '../../dishes/**/*');

async function checkFileSize(filePath) {
try {
const stats = await fs.stat(filePath);
return stats.size;
} catch (error) {
console.error(`Error checking file size for ${filePath}: ${error.message}`);
return 0;
}
// 工具函数:获取文件状态,包括大小
async function getFileStats(filePath) {
try {
const stats = await fs.stat(filePath);
return stats;
} catch (err) {
console.error(`检查文件状态时出错: ${filePath} -> ${err.message}`);
return null;
}
}

async function main() {
var errors = [];
var directories = await glob(__dirname + '../../dishes/**/*.md');
// 工具函数:读取文件内容并按行返回
async function readLines(filePath) {
const content = await fs.readFile(filePath, 'utf8');
return content.split('\n').map(line => line.trim());
}

// Check all files in dishes directory for size
var allFiles = await glob(__dirname + '../../dishes/**/*');
// 校验函数集合
const validators = [
async (filePath, lines, errors) => {
const filenameWithoutExt = path.parse(filePath).name; // .name 是不带扩展名的文件名
if (filenameWithoutExt.includes(' ')) {
errors.push(`文件 ${filePath} 不符合仓库的规范!文件名不能包含空格! (当前文件名: ${filenameWithoutExt})`);
}
},

// Check each file size
for (var filePath of allFiles) {
const fileSize = await checkFileSize(filePath);
if (fileSize > MAX_FILE_SIZE) {
errors.push(`文件 ${filePath} 超过了1MB大小限制 (${(fileSize/1048576).toFixed(2)}MB)! 请压缩图片或分割文件。`);
}

async (filePath, lines, errors) => {
const filenameWithoutExt = path.parse(filePath).name;
const expectedMainTitle = `# ${filenameWithoutExt}的做法`;
const titles = lines.filter(l => l.startsWith('#'));

if (!titles.length || titles[0] !== expectedMainTitle) {
errors.push(`文件 ${filePath} 不符合仓库的规范!它的大标题应该是: "${expectedMainTitle}"! 而它现在是 "${titles[0] || '未找到主标题'}"!`);
return;
}

// Check for files without extensions
for (var filePath of allFiles) {
const stats = await fs.stat(filePath);
// Only check files (not directories)
if (stats.isFile()) {
const extension = path.extname(filePath);
if (extension === '') {
errors.push(`文件 ${filePath} 不符合仓库的规范!文件必须有扩展名!`);
}
}
const sections = lines.filter(l => l.startsWith('## '));
const requiredSections = ['## 必备原料和工具', '## 计算', '## 操作', '## 附加内容'];


if (sections.length !== requiredSections.length) {
errors.push(`文件 ${filePath} 不符合仓库的规范!它并不是四个二级标题的格式 (应为 ${requiredSections.length} 个,实际 ${sections.length} 个)。请从示例菜模板中创建菜谱!请不要破坏模板的格式!`);
return;
}

for (var filePath of directories) {
var data = await fs.readFile(filePath, 'utf8');
var filename = path.parse(filePath).base.replace(".md","");
requiredSections.forEach((sec, idx) => {
if (sections[idx] !== sec) {
let titleName = "";
if (idx === 0) titleName = "第一个";
else if (idx === 1) titleName = "第二个";
else if (idx === 2) titleName = "第三个";
else if (idx === 3) titleName = "第四个";

if (filename.includes(' ')) {
errors.push(`文件 ${filePath} 不符合仓库的规范!文件名不能包含空格!`);
}

dataLines = data.split('\n').map(t => t.trim());
titles = dataLines.filter(t => t.startsWith('#'));
secondTitles = titles.filter(t => t.startsWith('## '));

if (dataLines.filter(line => line.includes('勺')).length >
dataLines.filter(line => line.includes('勺子')).length +
dataLines.filter(line => line.includes('炒勺')).length +
dataLines.filter(line => line.includes('漏勺')).length +
dataLines.filter(line => line.includes('吧勺')).length) {
errors.push(`文件 ${filePath} 不符合仓库的规范!勺 不是一个精准的单位!`);
}
if (dataLines.filter(line => line.includes(' 杯')).length >
dataLines.filter(line => line.includes('杯子')).length) {
errors.push(`文件 ${filePath} 不符合仓库的规范!杯 不是一个精准的单位!`);
}
if (dataLines.filter(line => line.includes('适量')).length > 0) {
errors.push(`文件 ${filePath} 不符合仓库的规范!适量 不是一个精准的描述!请给出克 g 或毫升 ml。`);
}
if (dataLines.filter(line => line.includes('每人')).length + dataLines.filter(line => line.includes('人数')).length > 0) {
errors.push(`文件 ${filePath} 不符合仓库的规范!请基于每道菜\\每份为基准。不要基于人数。人数是一个可能会导致在应用中发生问题的单位。如果需要面向大量的人食用,请标明一个人需要几份。`);
}
if (
dataLines.filter(line => line.includes('份数')).length > 0 &&
(
dataLines.filter(line => line.includes('总量')).length == 0 ||
dataLines.filter(line => line.includes('每次制作前需要确定计划做几份。一份正好够')).length == 0
)
) {
errors.push(`文件 ${filePath} 不符合仓库的规范!它使用份数作为基础,这种情况下一般是一次制作,制作多份的情况。请标明:总量 并写明 '每次制作前需要确定计划做几份。一份正好够 几 个人食用。'。`);
}
if (dataLines.filter(line => line.includes('min')).length > 0) {
errors.push(`文件 ${filePath} 不符合仓库的规范!min 这个词汇有多重含义。建议改成中文"分钟"。`);
}
if (dataLines.filter(line => line.includes('左右')).length > 0) {
errors.push(`文件 ${filePath} 不符合仓库的规范!左右 不是一个能够明确定量的标准! 如果是在描述一个模糊物体的特征,请使用 '大约'。例如:鸡(大约1kg)`);
}
if (dataLines.filter(line => line.includes('少许')).length > 0) {
errors.push(`文件 ${filePath} 不符合仓库的规范!少许 不是一个精准的描述!请给出克 g 或毫升 ml。`);
}
if (dataLines.filter(line => line.includes('你')).length + dataLines.filter(line => line.includes('我')).length > 0) {
errors.push(`文件 ${filePath} 不符合仓库的规范!请不要出现人称代词。`);
}
if (titles[0].trim() != "# " + filename + "的做法") {
errors.push(`文件 ${filePath} 不符合仓库的规范!它的大标题应该是: ${"# " + filename + "的做法"}! 而它现在是 ${titles[0].trim()}!`);
continue;
}
errors.push(`文件 ${filePath} 不符合仓库的规范!${titleName}标题不是 ${sec}! (当前为: "${sections[idx] || '未找到'}")`);
}
});

// 检查烹饪难度
const mainTitleIndex = dataLines.indexOf(titles[0].trim());
const firstSecondTitleIndex = dataLines.indexOf(secondTitles[0].trim());

if (mainTitleIndex >= 0 && firstSecondTitleIndex >= 0) {
// 检查大标题和第一个二级标题之间是否有预估烹饪难度
let hasDifficulty = false;
const difficultyPattern = /^预估烹饪难度:★{1,5}$/;

for (let i = mainTitleIndex + 1; i < firstSecondTitleIndex; i++) {
if (difficultyPattern.test(dataLines[i])) {
hasDifficulty = true;
// 检查星星数量是否在1-5之间
const starCount = (dataLines[i].match(/★/g) || []).length;
if (starCount < 1 || starCount > 5) {
errors.push(`文件 ${filePath} 不符合仓库的规范!烹饪难度的星星数量必须在1-5颗之间!`);
}
break;
}
}

if (!hasDifficulty) {
errors.push(`文件 ${filePath} 不符合仓库的规范!在大标题和第一个二级标题之间必须包含"预估烹饪难度:★★"格式的难度评级,星星数量必须在1-5颗之间!`);
}
}


if (secondTitles.length != 4) {
errors.push(`文件 ${filePath} 不符合仓库的规范!它并不是四个标题的格式。请从示例菜模板中创建菜谱!请不要破坏模板的格式!`);
continue;
}
if (secondTitles[0].trim() != "## 必备原料和工具") {
errors.push(`文件 ${filePath} 不符合仓库的规范!第一个标题不是 必备原料和工具!`);
}
if (secondTitles[1].trim() != "## 计算") {
errors.push(`文件 ${filePath} 不符合仓库的规范!第二个标题不是 计算!`);
}
if (secondTitles[2].trim() != "## 操作") {
errors.push(`文件 ${filePath} 不符合仓库的规范!第三个标题不是 操作`);
}
if (secondTitles[3].trim() != "## 附加内容") {
errors.push(`文件 ${filePath} 不符合仓库的规范!第四个标题不是 附加内容`);
}
// 检查烹饪难度
const mainTitleIndex = titles.length > 0 ? lines.indexOf(titles[0]) : -1;
const firstSecondTitleIndex = sections.length > 0 ? lines.indexOf(sections[0]) : -1;

var mustHave = '如果您遵循本指南的制作流程而发现有问题或可以改进的流程,请提出 Issue 或 Pull request 。';
var mustHaveIndex = dataLines.indexOf(mustHave);
if (mustHaveIndex < 0) {
errors.push(`文件 ${filePath} 不符合仓库的规范! 它没有包含必需的附加内容!,需要在最后一行添加模板中的【如果您遵循本指南的制作流程而发现有……】`);
}
if (mainTitleIndex >= 0 && firstSecondTitleIndex >= 0 && mainTitleIndex < firstSecondTitleIndex) {
const contentBetweenTitles = lines.slice(mainTitleIndex + 1, firstSecondTitleIndex);
let hasDifficultyLine = false;
const difficultyPatternGeneral = /^预估烹饪难度:(★*)$/;
const difficultyPatternStrict = /^预估烹饪难度:★{1,5}$/;

for (const line of contentBetweenTitles) {
if (difficultyPatternGeneral.test(line)) {
hasDifficultyLine = true;
if (!difficultyPatternStrict.test(line)) {
const starMatch = line.match(/★/g);
const starCount = starMatch ? starMatch.length : 0;
errors.push(`文件 ${filePath} 不符合仓库的规范!烹饪难度的星星数量必须在1-5颗之间!(当前为 ${starCount} 颗)`);
}
break;
}
}
if (!hasDifficultyLine) {
errors.push(`文件 ${filePath} 不符合仓库的规范!在大标题和第一个二级标题之间必须包含"预估烹饪难度:★★"格式的难度评级,星星数量必须在1-5颗之间!`);
}
} else if (mainTitleIndex === -1 || firstSecondTitleIndex === -1) {
errors.push(`文件 ${filePath} 结构错误,无法定位烹饪难度区域。`);
}

if (errors.length > 0) {
for (var error of errors) {
console.error(error + "\n");
}
},


async (filePath, lines, errors) => {
const count = keyword => lines.filter(l => l.includes(keyword)).length;

if (count('勺') > count('勺子') + count('炒勺') + count('漏勺') + count('吧勺')) {
errors.push(`文件 ${filePath} 不符合仓库的规范!勺 不是一个精准的单位!`);
}
if (count(' 杯') > count('杯子')) {
errors.push(`文件 ${filePath} 不符合仓库的规范!杯 不是一个精准的单位!`);
}
['适量', '少许'].forEach(w => {
if (count(w) > 0) {
errors.push(`文件 ${filePath} 不符合仓库的规范!${w} 不是一个精准的描述!请给出克 g 或毫升 ml。`);
}
});
if (count('min') > 0) {
errors.push(`文件 ${filePath} 不符合仓库的规范!min 这个词汇有多重含义。建议改成中文"分钟"。`);
}
if (count('左右') > 0) {
errors.push(`文件 ${filePath} 不符合仓库的规范!左右 不是一个能够明确定量的标准! 如果是在描述一个模糊物体的特征,请使用 '大约'。例如:鸡(大约1kg)`);
}
['你', '我'].forEach(pronoun => {
if (count(pronoun) > 0) {
errors.push(`文件 ${filePath} 不符合仓库的规范!请不要出现人称代词。`);
}
});
},


async (filePath, lines, errors) => {
const hasPortion = lines.some(l => l.includes('份数'));
const hasTotal = lines.some(l => l.includes('总量'));
const hasTemplateLine = lines.some(l => l.includes('每次制作前需要确定计划做几份。一份正好够'));

if (hasPortion && (!hasTotal || !hasTemplateLine)) {
errors.push(`文件 ${filePath} 不符合仓库的规范!它使用份数作为基础,这种情况下一般是一次制作,制作多份的情况。请标明:总量 并写明 '每次制作前需要确定计划做几份。一份正好够 几 个人食用。'。`);
}
if (lines.some(l => l.includes('每人') || l.includes('人数'))) {
errors.push(`文件 ${filePath} 不符合仓库的规范!请基于每道菜\\每份为基准。不要基于人数。人数是一个可能会导致在应用中发生问题的单位。如果需要面向大量的人食用,请标明一个人需要几份。`);
}
},

var message = `Found ${errors.length} errors! Please fix!`;
throw new Error(message);

async (filePath, lines, errors) => {
const footer = '如果您遵循本指南的制作流程而发现有问题或可以改进的流程,请提出 Issue 或 Pull request 。';
if (!lines.includes(footer)) {
errors.push(`文件 ${filePath} 不符合仓库的规范! 它没有包含必需的附加内容!,需要在最后一行添加模板中的【${footer}】`);
}
}
];


async function main() {
const errors = [];
// 获取所有文件和 Markdown 文件路径
const allPaths = await glob(ALL_FILES_GLOB);
const mdPaths = await glob(DISHES_GLOB);

// 检查文件大小和扩展名
for (const p of allPaths) {
const stats = await getFileStats(p);
if (!stats) { // 如果获取状态失败,跳过后续检查
errors.push(`无法获取文件状态: ${p},跳过此文件的检查。`);
continue;
}

if (stats.size > MAX_FILE_SIZE) {
errors.push(`文件 ${p} 超过了1MB大小限制 (${(stats.size/1048576).toFixed(2)}MB)! 请压缩图片或分割文件。`);
}

// 检查扩展名
if (stats.isFile()) {
const ext = path.extname(p);
if (!ext) {
errors.push(`文件 ${p} 不符合仓库的规范!文件必须有扩展名!`);
}
}
}

// 对 Markdown 文件逐项校验内容
for (const p of mdPaths) {
const lines = await readLines(p);
for (const validate of validators) {
await validate(p, lines, errors);
}
}

// 输出错误并退出
if (errors.length) {
errors.forEach(e => console.error(e + "\n"));
const message = `Found ${errors.length} errors! Please fix!`;
throw new Error(message);
} else {
console.log("所有检查已通过!没有发现错误。");
}
}

main();

main().catch(err => {
console.error("\n" + err.message);
process.exit(1);
});