#!/usr/bin/env node import assert from "node:assert/strict"; import fs from "node:fs"; import indexToPosition from "index-to-position"; import { outdent } from "outdent"; import remarkParse from "remark-parse"; import unified from "unified"; import { CHANGELOG_CATEGORIES } from "./utilities/changelog.js"; const CHANGELOG_DIR = "changelog_unreleased"; const TEMPLATE_FILE = "TEMPLATE.md"; const BLOG_POST_INTRO_TEMPLATE_FILE = "BLOG_POST_INTRO_TEMPLATE.md"; const BLOG_POST_INTRO_FILE = "blog-post-intro.md"; const CHANGELOG_ROOT = new URL(`../${CHANGELOG_DIR}/`, import.meta.url); const showErrorMessage = (message) => { console.error(message); process.exitCode = 1; }; const files = fs.readdirSync(CHANGELOG_ROOT); for (const file of files) { if ( file !== TEMPLATE_FILE && file !== BLOG_POST_INTRO_FILE && file !== BLOG_POST_INTRO_TEMPLATE_FILE && !CHANGELOG_CATEGORIES.includes(file) ) { showErrorMessage(`Please remove "${file}" from "${CHANGELOG_DIR}".`); } } for (const file of [ TEMPLATE_FILE, BLOG_POST_INTRO_TEMPLATE_FILE, ...CHANGELOG_CATEGORIES, ]) { if (!files.includes(file)) { showErrorMessage(`Please don't remove "${file}" from "${CHANGELOG_DIR}".`); } } const authorRegex = /by @[\w-]+|by \[@([\w-]+)\]\(https:\/\/github\.com\/\1\)/; const titleRegex = /^#{4} (.*?)\((#\d{4,}|\[#\d{4,}\])/; const template = fs.readFileSync( new URL(TEMPLATE_FILE, CHANGELOG_ROOT), "utf8", ); const templateComments = template.match(//gs); const [templateAuthorLink] = template.match(authorRegex); const checkedFiles = new Map(); for (const category of CHANGELOG_CATEGORIES) { const files = fs.readdirSync(new URL(`${category}/`, CHANGELOG_ROOT)); if (!files.includes(".gitkeep")) { showErrorMessage( `Please don't remove ".gitkeep" from "${CHANGELOG_DIR}/${category}".`, ); } for (const prFile of files) { if (prFile === ".gitkeep") { continue; } const match = prFile.match(/^(\d{4,})(-\d+)?\.md$/); const displayPath = `${CHANGELOG_DIR}/${category}/${prFile}`; if (!match) { showErrorMessage( `[${displayPath}]: Filename is not in form of "{PR_NUMBER}.md".`, ); continue; } const [, prNumber] = match; const prLink = `#${prNumber}`; if (checkedFiles.has(prFile)) { showErrorMessage( outdent` Duplicate files for ${prLink} found. - ${checkedFiles.get(prFile)} - ${displayPath} `, ); } checkedFiles.set(prFile, displayPath); const content = fs.readFileSync( new URL(`${category}/${prFile}`, CHANGELOG_ROOT), "utf8", ); if (!content.includes(prLink)) { showErrorMessage(`[${displayPath}]: PR link "${prLink}" is missing.`); } if (!authorRegex.test(content)) { showErrorMessage(`[${displayPath}]: Author link is missing.`); } for (const comment of templateComments) { if (comment !== "" && content.includes(comment)) { showErrorMessage( `[${displayPath}]: Please remove ${getCommentDescription( content, comment, )}`, ); } } if (content.includes(templateAuthorLink)) { showErrorMessage( `[${displayPath}]: Please change author link to your github account.`, ); } if (!content.startsWith("#### ")) { showErrorMessage(`[${displayPath}]: Please use h4 ("####") for title.`); } const titleMatch = content.match(titleRegex); if (!titleMatch) { showErrorMessage(`[${displayPath}]: Something wrong in title.`); continue; } const [, title] = titleMatch; validateTitle(displayPath, title); const categoryInTitle = title.split(":").shift().trim(); if ( [...CHANGELOG_CATEGORIES, "js"].includes(categoryInTitle.toLowerCase()) ) { showErrorMessage( `[${displayPath}]: Please remove "${categoryInTitle}:" in title.`, ); } if (!title.endsWith(" ") || title.length - title.trimEnd().length !== 1) { showErrorMessage( `[${displayPath}]: Please put one space between title and PR link.`, ); } if (/prettier master/i.test(content)) { showErrorMessage( `[${displayPath}]: Please use "main" instead of "master".`, ); } } } function getCommentDescription(content, comment) { const start = content.indexOf(comment); const end = start + comment.length; const [startLine, endLine] = [start, end].map( (index) => indexToPosition(content, index, { oneBased: true }).line, ); if (startLine === endLine) { return `template comment "${comment}" on line ${startLine}`; } return `template comment on line ${startLine}-${endLine}`; } // Forbid HTML in title // https://github.com/prettier/prettier/issues/17089 function validateTitle(displayPath, title) { const processor = unified().use(remarkParse); const tree = processor.runSync(processor.parse(title)); assert.equal(tree.children.length, 1); assert.equal(tree.children[0].type, "paragraph"); const { children } = tree.children[0]; for (const node of children) { if (node.type === "html") { showErrorMessage( `[${displayPath}]: HTML "${node.value}" is restricted in title.`, ); } } }