fix(custom-widgets): harden v2 authoring
This commit is contained in:
171 files changed
+20311
-50527
No files matched your search
@@ -0,0 +1,217 @@
|
||||
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}`);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
export const technicalTerms = [
|
||||
"Homarr",
|
||||
"Custom Widget",
|
||||
"Custom JSX v2",
|
||||
"Workshop",
|
||||
"API source",
|
||||
"named request",
|
||||
"load query",
|
||||
"manual query",
|
||||
"action",
|
||||
"widget option",
|
||||
"temporary input",
|
||||
"CodeMirror",
|
||||
"Mantine",
|
||||
"PocketBase",
|
||||
"MCP",
|
||||
"JSON Schema",
|
||||
"API key",
|
||||
"network scope",
|
||||
"preview session",
|
||||
"error boundary",
|
||||
"HTTP",
|
||||
"JSON",
|
||||
"JSX",
|
||||
"React",
|
||||
"TypeScript",
|
||||
"Zod",
|
||||
"TLS",
|
||||
"DNS",
|
||||
"CORS",
|
||||
"OAuth",
|
||||
"SSRF",
|
||||
"tRPC",
|
||||
"GitHub",
|
||||
"Docker",
|
||||
"reverse proxy",
|
||||
"container networking",
|
||||
"API authentication",
|
||||
"bearer token",
|
||||
"basic authentication",
|
||||
"primitive literal",
|
||||
"prop metadata",
|
||||
"allowlist",
|
||||
"denylist",
|
||||
];
|
||||
|
||||
export const imperativeVerbs = [
|
||||
"add",
|
||||
"bind",
|
||||
"build",
|
||||
"call",
|
||||
"check",
|
||||
"configure",
|
||||
"correct",
|
||||
"create",
|
||||
"declare",
|
||||
"define",
|
||||
"delete",
|
||||
"disable",
|
||||
"download",
|
||||
"enable",
|
||||
"enter",
|
||||
"examine",
|
||||
"export",
|
||||
"get",
|
||||
"give",
|
||||
"import",
|
||||
"inspect",
|
||||
"install",
|
||||
"keep",
|
||||
"open",
|
||||
"paste",
|
||||
"read",
|
||||
"register",
|
||||
"remove",
|
||||
"repeat",
|
||||
"request",
|
||||
"run",
|
||||
"save",
|
||||
"select",
|
||||
"set",
|
||||
"start",
|
||||
"supply",
|
||||
"test",
|
||||
"use",
|
||||
"verify",
|
||||
"wait",
|
||||
"write",
|
||||
];
|
||||
|
||||
export const britishSpellings = [
|
||||
"authorise",
|
||||
"authorised",
|
||||
"behaviour",
|
||||
"cancelled",
|
||||
"centre",
|
||||
"colour",
|
||||
"customise",
|
||||
"customised",
|
||||
"initialise",
|
||||
"labelling",
|
||||
"licence",
|
||||
"organisation",
|
||||
"recognise",
|
||||
"serialise",
|
||||
"synchronise",
|
||||
"utilise",
|
||||
"visualise",
|
||||
];
|
||||
|
||||
export const nonSteTerms = [
|
||||
"easy",
|
||||
"easily",
|
||||
"genuinely",
|
||||
"leverage",
|
||||
"magic",
|
||||
"obviously",
|
||||
"seamless",
|
||||
"seamlessly",
|
||||
"simply",
|
||||
"straightforward",
|
||||
"utilize",
|
||||
"utilized",
|
||||
"wizard",
|
||||
];
|
||||
Reference in new issue
Block a user