homebridge-plugin-utils
Version:
Opinionated utilities to provide common capabilities and create rich configuration webUI experiences for Homebridge plugins.
512 lines (377 loc) • 14.9 kB
JavaScript
/* Copyright(C) 2017-2026, HJD (https://github.com/hjdhjd). All rights reserved.
*
* eslint-rules.mjs: Opinionated default linting rules for Homebridge plugins.
*/
import eslintJs from "@eslint/js";
import stylistic from "@stylistic/eslint-plugin";
import ts from "typescript-eslint";
const ruleBlankAfterOpenBrace = {
create(context) {
function checkNode(node) {
const sourceCode = context.sourceCode;
const openBrace = sourceCode.getFirstToken(node);
const nextToken = sourceCode.getTokenAfter(openBrace);
if(openBrace.loc.end.line < nextToken.loc.start.line) {
const nextLine = sourceCode.lines[openBrace.loc.end.line];
if(nextLine.trim() !== "") {
context.report({
fix(fixer) {
return fixer.insertTextAfter(openBrace, "\n\n");
},
loc: openBrace.loc,
message: "Expected blank line after left brace and newline.",
node
});
}
}
}
return {
// Validate block statements.
BlockStatement(node) {
checkNode(node);
},
// Validate class declarations and any embedded methods or objects.
ClassBody(node) {
checkNode(node);
for(const element of node.body) {
if((![ "MethodDefinition", "PropertyDefinition" ].includes(element.type)) || (element.value?.type !== "ObjectExpression")) {
continue;
}
checkNode(node);
}
},
// Validate object expressions.
ObjectExpression(node) {
checkNode(node);
},
// Validate interface declarations and any embedded objects.
TSInterfaceBody(node) {
checkNode(node);
for(const property of node.body) {
if(!property.typeAnnotation || (property.type !== "TSPropertySignature") || (property.typeAnnotation.typeAnnotation.type !== "TSTypeLiteral")) {
continue;
}
checkNode(property.typeAnnotation.typeAnnotation);
}
},
// Validate type declarations and any embedded objects.
TSTypeAliasDeclaration(node) {
if(node.typeAnnotation.type !== "TSTypeLiteral") {
return;
}
checkNode(node.typeAnnotation);
for(const member of node.typeAnnotation.members) {
if(!member.typeAnnotation || (member.type !== "TSPropertySignature") || (member.typeAnnotation.typeAnnotation.type !== "TSTypeLiteral")) {
continue;
}
checkNode(member.typeAnnotation.typeAnnotation);
}
}
};
},
meta: {
docs: {
category: "Stylistic Issues",
description: "require a blank line after an opening brace if it is immediately followed by a newline",
recommended: false
},
fixable: "whitespace",
schema: [],
type: "layout"
}
};
const ruleParenComparisonsInLogical = {
create(context) {
const sourceCode = context.sourceCode;
// Comparison operators to wrap when used inside && / || compounds.
const comparisonOperators = new Set([ "==", "!=", "===", "!==", "<", "<=", ">", ">=", "in", "instanceof" ]);
const logicalOperators = new Set([ "&&", "||" ]);
function isComparison(node) {
return !!node &&
(node.type === "BinaryExpression") &&
comparisonOperators.has(node.operator);
}
function isParenthesized(node) {
// Prefer ESLint's helper when available.
if(typeof sourceCode.isParenthesized === "function") {
return sourceCode.isParenthesized(node);
}
// Fallback token check.
const before = sourceCode.getTokenBefore(node);
const after = sourceCode.getTokenAfter(node);
return !!before && !!after && (before.value === "(") && (after.value === ")");
}
function checkOperand(node) {
if(!isComparison(node) || isParenthesized(node)) {
return;
}
context.report({
fix(fixer) {
return fixer.replaceText(node, "(" + sourceCode.getText(node) + ")");
},
message: "Wrap comparison operands in parentheses inside compound logical expressions (&&, ||).",
node
});
}
return {
LogicalExpression(node) {
if(!logicalOperators.has(node.operator)) {
return;
}
// Only wrap direct operands of && / || when they are comparisons.
checkOperand(node.left);
checkOperand(node.right);
}
};
},
meta: {
docs: {
category: "Stylistic Issues",
description: "require parentheses around comparison operands when used inside compound logical expressions (&&, ||)",
recommended: false
},
fixable: "code",
schema: [],
type: "layout"
}
};
// Enforce no space between `catch` and `(` when a parameter is present. Parameterless catch clauses (e.g., `catch {`) are ignored and handled by space-before-blocks.
const ruleCatchParenSpacing = {
create(context) {
const sourceCode = context.sourceCode;
return {
CatchClause(node) {
// Only check catch clauses that have a parameter.
if(!node.param) {
return;
}
// Get the `catch` keyword token and the opening parenthesis token.
const catchKeyword = sourceCode.getFirstToken(node);
const openParen = sourceCode.getTokenAfter(catchKeyword);
// Check if there is whitespace between `catch` and `(`.
if((openParen.value === "(") && (catchKeyword.range[1] < openParen.range[0])) {
context.report({
fix(fixer) {
return fixer.removeRange([ catchKeyword.range[1], openParen.range[0] ]);
},
loc: {
end: openParen.loc.start,
start: catchKeyword.loc.end
},
message: "Unexpected space between `catch` and `(`.",
node
});
}
}
};
},
meta: {
docs: {
category: "Stylistic Issues",
description: "disallow space between `catch` and `(` when a parameter is present",
recommended: false
},
fixable: "whitespace",
schema: [],
type: "layout"
}
};
// Extract rules by name from a typescript-eslint config array. Throws if the named entry is missing so that a typescript-eslint version bump that renames config entries
// fails loudly rather than silently dropping rules.
function configRules(configs, name) {
const entry = configs.find((c) => c.name === name);
if(!entry) {
throw new Error("typescript-eslint config entry not found: " + name);
}
return entry.rules;
}
// ESlint plugins to use.
const plugins = {
"@hjdhjd": {
"rules": {
"blank-line-after-open-brace": ruleBlankAfterOpenBrace,
"catch-paren-spacing": ruleCatchParenSpacing,
"paren-comparisons-in-logical": ruleParenComparisonsInLogical
}
},
"@stylistic": stylistic,
"@typescript-eslint": ts.plugin
};
// TypeScript-specific rules.
const tsRules = {
...configRules(ts.configs.strictTypeChecked, "typescript-eslint/strict-type-checked"),
...configRules(ts.configs.stylisticTypeChecked, "typescript-eslint/stylistic-type-checked"),
"@stylistic/member-delimiter-style": "warn",
"@typescript-eslint/await-thenable": "warn",
"@typescript-eslint/consistent-type-imports": "warn",
"@typescript-eslint/explicit-function-return-type": "warn",
"@typescript-eslint/explicit-module-boundary-types": "warn",
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-floating-promises": [ "warn", { "ignoreIIFE": true } ],
"@typescript-eslint/no-non-null-assertion": "warn",
"@typescript-eslint/no-unnecessary-condition": "warn",
"@typescript-eslint/no-unused-expressions": "warn",
"@typescript-eslint/no-unused-vars": [ "warn", { "caughtErrors": "none" } ],
"@typescript-eslint/prefer-nullish-coalescing": "warn",
"@typescript-eslint/promise-function-async": "warn",
"require-await": "off",
// eslint-disable-next-line sort-keys
"@typescript-eslint/require-await": "warn",
"no-dupe-class-members": "off",
"no-redeclare": "off",
"no-undef": "off",
"no-unused-expressions": "off",
"no-unused-vars": "off"
};
// JavaScript-specific rules.
const jsRules = {
...ts.configs.disableTypeChecked.rules,
"@typescript-eslint/no-floating-promises": "off",
"require-await": "warn"
};
// Rules that exist across both JavaScript and TypeScript files.
const commonRules = {
...ts.configs.eslintRecommended.rules,
"@hjdhjd/blank-line-after-open-brace": "warn",
"@hjdhjd/catch-paren-spacing": "warn",
"@hjdhjd/paren-comparisons-in-logical": "warn",
"@stylistic/array-bracket-spacing": [ "warn", "always", { "arraysInArrays": true, "objectsInArrays": true, "singleValue": false } ],
"@stylistic/block-spacing": "warn",
"@stylistic/brace-style": [ "warn", "1tbs", { "allowSingleLine": true } ],
"@stylistic/comma-dangle": "warn",
"@stylistic/eol-last": [ "warn", "always" ],
"@stylistic/generator-star-spacing": "warn",
"@stylistic/implicit-arrow-linebreak": "warn",
"@stylistic/indent": [ "warn", 2, { "SwitchCase": 1 } ],
"@stylistic/keyword-spacing": [ "warn",
{ "overrides": { "for": { "after": false }, "if": { "after": false }, "switch": { "after": false}, "while": { "after": false } } } ],
"@stylistic/linebreak-style": [ "warn", "unix" ],
"@stylistic/lines-between-class-members": [ "warn", "always", { "exceptAfterSingleLine": true } ],
"@stylistic/max-len": [ "warn", 170 ],
"@stylistic/no-tabs": "warn",
"@stylistic/no-trailing-spaces": "warn",
"@stylistic/operator-linebreak": [ "warn", "after", { "overrides": { ":": "after", "?": "after" } } ],
"@stylistic/padding-line-between-statements": [ "warn",
// Require a blank line before every statement type in next.
{ "blankLine": "always", "next": [ "break", "case", "class", "continue", "default", "export", "for", "function", "if", "import", "return" ], "prev": "*" },
// Require blank lines after every statement type in prev.
{ "blankLine": "always", "next": "*", "prev": [ "const", "directive", "let", "var" ] },
// Multiple sequential case declarations may be grouped together.
{ "blankLine": "any", "next": [ "case", "default" ], "prev": [ "case", "default" ] },
// Multiple sequential variable declarations may be grouped together.
{ "blankLine": "any", "next": [ "const", "let", "var" ], "prev": [ "const", "let", "var" ] },
// Multiple sequential export declarations may be grouped together.
{ "blankLine": "any", "next": "export", "prev": "export" },
// Multiple sequential import declarations must be grouped together.
{ "blankLine": "never", "next": "import", "prev": "import" },
// Multiple sequential directive prologues must be grouped together.
{ "blankLine": "never", "next": "directive", "prev": "directive" }
],
"@stylistic/semi": [ "warn", "always" ],
"@stylistic/space-before-function-paren": [ "warn", { "anonymous": "never", "asyncArrow": "always", "catch": "never", "named": "never" } ],
"@stylistic/space-in-parens": "warn",
"@stylistic/space-infix-ops": "warn",
"@stylistic/space-unary-ops": "warn",
"@typescript-eslint/no-this-alias": "warn",
"camelcase": "warn",
"curly": [ "warn", "all" ],
"dot-notation": "warn",
"eqeqeq": "warn",
"logical-assignment-operators": [ "warn", "always", { "enforceForIfStatements": true } ],
"no-await-in-loop": "warn",
"no-console": "warn",
"no-restricted-syntax": [ "warn", "TemplateLiteral" ],
"prefer-arrow-callback": "warn",
"prefer-const": "warn",
"quotes": [ "warn", "double", { "allowTemplateLiterals": false, "avoidEscape": false } ],
"sort-imports": "warn",
"sort-keys": "warn",
"sort-vars": "warn"
};
// Globals that tend to exist in Homebridge plugins with a webUI.
const globalsUi = Object.fromEntries([ "clearTimeout", "console", "container", "document", "fetch", "getComputedStyle", "homebridge", "setTimeout", "window" ]
.map(key => [ key, "readonly" ]));
/* Build a ready-to-use ESLint flat config array. Consumers call this function and export default the result.
*
* @param {object} options - Configuration options.
* @param {string[]} options.ts - Glob patterns for TypeScript files (strict + stylistic type-checked rules).
* @param {string[]} options.js - Glob patterns for JavaScript files (disable-type-checked rules).
* @param {string[]} options.ui - Glob patterns for browser UI files (adds browser globals).
* @param {string[]} options.allowDefaultProject - Globs passed to parserOptions.projectService.allowDefaultProject.
* @param {string[]} options.ignores - Global ignore patterns (in addition to the hardcoded "dist" ignore on the common block).
* @param {object[]} options.extraConfigs - Additional flat config objects appended to the output.
* @returns {object[]} A flat config array suitable for `export default` in eslint.config.mjs. Block ordering is significant — do not reorder.
*/
function config({
allowDefaultProject = [],
extraConfigs = [],
ignores = [],
js = [],
ts: tsFiles = [],
ui = []
} = {}) {
const allFiles = [ ...tsFiles, ...js, ...ui ];
const configs = [];
// Global ignores block.
if(ignores.length > 0) {
configs.push({ ignores });
}
// Core ESLint recommended rules. This block must precede the TS/JS blocks so that their rule overrides (e.g., no-unused-vars: "off") take priority.
if(allFiles.length > 0) {
configs.push(eslintJs.configs.recommended);
}
// TypeScript-specific rules.
if(tsFiles.length > 0) {
configs.push({
files: tsFiles,
rules: { ...tsRules }
});
}
// JavaScript-specific rules.
if(js.length > 0) {
configs.push({
files: js,
rules: { ...jsRules }
});
}
// Common rules applied to all linted files.
if(allFiles.length > 0) {
configs.push({
files: allFiles,
ignores: ["dist"],
languageOptions: {
ecmaVersion: "latest",
parser: ts.parser,
parserOptions: {
ecmaVersion: "latest",
projectService: {
allowDefaultProject,
defaultProject: "./tsconfig.json"
}
},
sourceType: "module"
},
linterOptions: {
reportUnusedDisableDirectives: "error"
},
plugins: { ...plugins },
rules: { ...commonRules }
});
}
// UI globals block.
if(ui.length > 0) {
configs.push({
files: ui,
languageOptions: {
globals: { ...globalsUi }
}
});
}
// Escape hatch for project-specific config blocks.
configs.push(...extraConfigs);
return configs;
}
// Default export is the config function.
export default config;
// Named exports for advanced customization or gradual migration.
export { commonRules, config, globalsUi, jsRules, plugins, tsRules };