Files
homarr/apps/docs/scripts/check-custom-widgets-ste.mjs
T

218 lines
7.2 KiB
JavaScript

import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
import { britishSpellings, imperativeVerbs, nonSteTerms, technicalTerms } from "./custom-widgets-ste-terms.mjs";
const scriptDirectory = dirname(fileURLToPath(import.meta.url));
const docsDirectory = resolve(scriptDirectory, "../docs");
const files = [
{ path: "management/custom-widgets/agent-authoring.mdx" },
{ path: "management/custom-widgets/authoring.mdx" },
{ path: "management/custom-widgets/custom-jsx.mdx" },
{ path: "management/custom-widgets/index.mdx" },
{ path: "management/custom-widgets/requests-and-security.mdx" },
{ path: "management/custom-widgets/troubleshooting.mdx" },
{ path: "management/workshop/index.mdx" },
{ path: "widgets/custom-api/index.mdx" },
{ path: "management/api/index.mdx", section: "Get Custom Widget authoring resources" },
{ path: "management/mcp.mdx", section: "Create a Custom Widget with an AI agent", includeMatching: /Custom Widget/ },
{ path: "advanced/environment-variables/index.mdx", includeMatching: /WORKSHOP_API_URL/ },
{ path: "community/faq.mdx", section: "Can I add my own widgets? Do I need programming skills?" },
{ path: "management/backup.mdx", includeMatching: /Custom Widget/ },
];
const imperativeSet = new Set(imperativeVerbs);
const issues = [];
const overview = await readFile(resolve(docsDirectory, "management/custom-widgets/index.mdx"), "utf8");
if (!overview.includes("## Audience and prerequisites")) {
addIssue("management/custom-widgets/index.mdx", 1, "Add the required audience and prerequisites section.");
}
for (const file of files) {
const absolutePath = resolve(docsDirectory, file.path);
const source = await readFile(absolutePath, "utf8");
const selectedLines = selectLines(source, file);
const paragraphs = getProseParagraphs(selectedLines);
for (const paragraph of paragraphs) {
const sentences = splitSentences(paragraph.text);
if (sentences.length > 6) {
addIssue(file.path, paragraph.line, `Paragraph has ${sentences.length} sentences. The limit is 6.`);
}
for (const sentence of sentences) {
const wordCount = countWords(sentence);
const isProcedure = paragraph.isList || startsWithImperative(sentence);
const limit = isProcedure ? 20 : 25;
if (wordCount > limit) {
const type = isProcedure ? "Procedural" : "Descriptive";
addIssue(file.path, paragraph.line, `${type} sentence has ${wordCount} words. The limit is ${limit}.`);
}
}
checkTerms(file.path, paragraph.line, paragraph.text);
}
}
if (issues.length > 0) {
console.error("Custom Widget STE check failed:\n");
for (const issue of issues) console.error(`- ${issue}`);
process.exitCode = 1;
} else {
console.log(`Custom Widget STE check passed. ${technicalTerms.length} technical terms are registered.`);
}
function selectLines(source, file) {
const lines = source.split("\n").map((text, index) => ({ text, line: index + 1 }));
if (!file.section && !file.includeMatching) return lines;
const selected = [];
if (file.section) {
const start = lines.findIndex(({ text }) => text.trim().replace(/^#{2,6}\s+/, "") === file.section);
if (start === -1) throw new Error(`Section '${file.section}' was not found in ${file.path}`);
const headingLevel = lines[start].text.trim().match(/^#+/)?.[0].length ?? 2;
for (let index = start; index < lines.length; index += 1) {
const nextHeadingLevel = lines[index].text.trim().match(/^#+(?=\s)/)?.[0].length;
if (index > start && nextHeadingLevel && nextHeadingLevel <= headingLevel) break;
selected.push(lines[index]);
}
}
if (file.includeMatching) {
for (const line of lines) {
if (file.includeMatching.test(line.text) && !selected.some((item) => item.line === line.line))
selected.push(line);
}
}
return selected.toSorted((left, right) => left.line - right.line);
}
function getProseParagraphs(lines) {
const paragraphs = [];
let paragraph = null;
let inFence = false;
let inFrontmatter = false;
let inTemplateExport = false;
let inJsxTag = false;
const flush = () => {
if (paragraph?.text.trim()) paragraphs.push({ ...paragraph, text: paragraph.text.trim() });
paragraph = null;
};
for (const { text: originalText, line } of lines) {
const trimmed = originalText.trim();
if (line === 1 && trimmed === "---") {
inFrontmatter = true;
continue;
}
if (inFrontmatter && trimmed === "---") {
inFrontmatter = false;
continue;
}
if (trimmed.startsWith("```")) {
flush();
inFence = !inFence;
continue;
}
if (inFence) continue;
if (/^export const .*?=\s*`/.test(trimmed)) inTemplateExport = true;
if (inTemplateExport) {
if (/`;\s*$/.test(trimmed)) inTemplateExport = false;
continue;
}
if (/^(import|export)\s/.test(trimmed)) continue;
if (trimmed.startsWith("<")) inJsxTag = true;
if (inJsxTag) {
if (trimmed.includes(">")) inJsxTag = false;
continue;
}
let text = trimmed;
if (inFrontmatter) {
const match = text.match(/^(title|description|sidebar_label):\s*["']?(.*?)["']?$/);
if (!match) continue;
text = match[2];
}
if (!text || /^\|?\s*:?-+:?/.test(text)) {
flush();
continue;
}
const isList = /^\s*(?:[-*]|\d+\.)\s+/.test(text);
text = cleanMarkdown(text);
if (!text) {
flush();
continue;
}
if (isList || text.startsWith("#") || text.startsWith("|")) flush();
text = text
.replace(/^#{1,6}\s+/, "")
.replace(/^\s*(?:[-*]|\d+\.)\s+/, "")
.replace(/^\|\s*/, "")
.replace(/\s*\|\s*$/, "");
if (!paragraph) paragraph = { text, line, isList };
else paragraph.text += ` ${text}`;
if (isList || trimmed.startsWith("#") || trimmed.startsWith("|")) flush();
}
flush();
return paragraphs;
}
function cleanMarkdown(text) {
return text
.replace(/\[([^\]]+)]\([^\s)]+(?:\s+"[^"]*")?\)/g, "$1")
.replace(/`[^`]*`/g, " ")
.replace(/<[^>]+>/g, " ")
.replace(/[*_~]/g, "")
.replace(/https?:\/\/\S+/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function splitSentences(text) {
const normalized = text.replace(/\b(?:for example|such as)\b/gi, "example");
const matches = normalized.match(/[^.!?]+[.!?]+|[^.!?]+$/g) ?? [];
return matches.map((sentence) => sentence.trim()).filter(Boolean);
}
function countWords(sentence) {
return sentence.match(/[A-Za-z0-9]+(?:[-'][A-Za-z0-9]+)*/g)?.length ?? 0;
}
function startsWithImperative(sentence) {
const firstWord = sentence.toLowerCase().match(/[a-z]+/)?.[0];
return firstWord ? imperativeSet.has(firstWord) : false;
}
function checkTerms(path, line, text) {
const lowerText = text.toLowerCase();
for (const spelling of britishSpellings) {
if (new RegExp(`\\b${spelling}\\b`, "i").test(text)) {
addIssue(path, line, `Use American English instead of '${spelling}'.`);
}
}
for (const term of nonSteTerms) {
if (new RegExp(`\\b${term}\\b`, "i").test(lowerText)) {
addIssue(path, line, `Replace the non-STE term '${term}'.`);
}
}
}
function addIssue(path, line, message) {
issues.push(`${path}:${line} ${message}`);
}