fix(custom-widgets): harden v2 authoring

This commit is contained in:
Thomas Camlong committed 2026-07-20 22:16:18 +02:00
1 parent ee832ad3c6
commit 7c66c912e7
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",
];