Skip to content

Commit 4778bde

Browse files
committed
feat: preferActiveDoc
1 parent 0cadd36 commit 4778bde

File tree

3 files changed

+237
-0
lines changed

3 files changed

+237
-0
lines changed

lib/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import noViewReferencesInPlugin from "./rules/noViewReferencesInPlugin.js";
1313
import objectAssign from "./rules/objectAssign.js";
1414
import platform from "./rules/platform.js";
1515
import preferAbstractInputSuggest from "./rules/preferAbstractInputSuggest.js";
16+
import preferActiveDoc from "./rules/preferActiveDoc.js";
1617
import preferFileManagerTrashFile from "./rules/preferFileManagerTrashFile.js";
1718
import regexLookbehind from "./rules/regexLookbehind.js";
1819
import sampleNames from "./rules/sampleNames.js";
@@ -71,6 +72,7 @@ const plugin = {
7172
"object-assign": objectAssign,
7273
platform: platform,
7374
"prefer-abstract-input-suggest": preferAbstractInputSuggest,
75+
"prefer-active-doc": preferActiveDoc,
7476
"prefer-file-manager-trash-file": preferFileManagerTrashFile,
7577
"regex-lookbehind": regexLookbehind,
7678
"sample-names": sampleNames,
@@ -108,6 +110,7 @@ const recommendedPluginRulesConfig: RulesConfig = {
108110
"obsidianmd/platform": "error",
109111
"obsidianmd/prefer-file-manager-trash-file": "warn",
110112
"obsidianmd/prefer-abstract-input-suggest": "error",
113+
"obsidianmd/prefer-active-doc": "error",
111114
"obsidianmd/regex-lookbehind": "error",
112115
"obsidianmd/sample-names": "error",
113116
"obsidianmd/validate-manifest": "error",

lib/rules/preferActiveDoc.ts

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { ESLintUtils, TSESTree } from "@typescript-eslint/utils";
2+
3+
const ruleCreator = ESLintUtils.RuleCreator(
4+
(name) =>
5+
`https://github.com/obsidianmd/eslint-plugin/blob/master/docs/rules/${name}.md`,
6+
);
7+
8+
const REPLACEMENTS: Record<string, string> = {
9+
document: "activeDocument",
10+
window: "activeWindow",
11+
};
12+
13+
const BANNED_GLOBALS = new Set(["global", "globalThis"]);
14+
15+
export default ruleCreator({
16+
name: "prefer-active-doc",
17+
meta: {
18+
type: "suggestion" as const,
19+
docs: {
20+
description:
21+
"Prefer `activeDocument` and `activeWindow` over `document` and `window` for popout window compatibility.",
22+
},
23+
schema: [],
24+
fixable: "code" as const,
25+
messages: {
26+
preferActive:
27+
"Use '{{replacement}}' instead of '{{original}}' for popout window compatibility.",
28+
avoidGlobal:
29+
"Avoid using '{{name}}'. Use 'activeWindow' or 'activeDocument' for popout window compatibility.",
30+
},
31+
},
32+
defaultOptions: [],
33+
create(context) {
34+
return {
35+
Identifier(node: TSESTree.Identifier) {
36+
if (BANNED_GLOBALS.has(node.name)) {
37+
return reportBannedGlobal(node);
38+
}
39+
40+
const replacement = REPLACEMENTS[node.name];
41+
if (!replacement) {
42+
return;
43+
}
44+
45+
// Skip if this is a property access (e.g., `obj.document`)
46+
if (
47+
node.parent.type === TSESTree.AST_NODE_TYPES.MemberExpression &&
48+
node.parent.property === node
49+
) {
50+
return;
51+
}
52+
53+
// Skip if this is a property key in an object literal
54+
if (
55+
node.parent.type === TSESTree.AST_NODE_TYPES.Property &&
56+
node.parent.key === node
57+
) {
58+
return;
59+
}
60+
61+
// Skip if this is a declaration (variable, function param, etc.)
62+
if (
63+
node.parent.type === TSESTree.AST_NODE_TYPES.VariableDeclarator &&
64+
node.parent.id === node
65+
) {
66+
return;
67+
}
68+
69+
// Skip typeof expressions (typeof window === 'undefined')
70+
if (node.parent.type === TSESTree.AST_NODE_TYPES.UnaryExpression && node.parent.operator === "typeof") {
71+
return;
72+
}
73+
74+
// Check scope: only flag global references, not local variables named document/window
75+
const scope = context.sourceCode.getScope(node);
76+
const variable = findVariable(scope, node.name);
77+
if (variable && variable.defs.length > 0) {
78+
return;
79+
}
80+
81+
context.report({
82+
node,
83+
messageId: "preferActive",
84+
data: {
85+
original: node.name,
86+
replacement,
87+
},
88+
fix(fixer) {
89+
return fixer.replaceText(node, replacement);
90+
},
91+
});
92+
},
93+
};
94+
95+
function reportBannedGlobal(node: TSESTree.Identifier): void {
96+
// Same skip logic as replaceable globals
97+
if (
98+
(node.parent.type === TSESTree.AST_NODE_TYPES.MemberExpression && node.parent.property === node) ||
99+
(node.parent.type === TSESTree.AST_NODE_TYPES.Property && node.parent.key === node) ||
100+
(node.parent.type === TSESTree.AST_NODE_TYPES.VariableDeclarator && node.parent.id === node) ||
101+
(node.parent.type === TSESTree.AST_NODE_TYPES.UnaryExpression && node.parent.operator === "typeof")
102+
) {
103+
return;
104+
}
105+
106+
const scope = context.sourceCode.getScope(node);
107+
const variable = findVariable(scope, node.name);
108+
if (variable && variable.defs.length > 0) {
109+
return;
110+
}
111+
112+
context.report({
113+
node,
114+
messageId: "avoidGlobal",
115+
data: { name: node.name },
116+
});
117+
}
118+
119+
function findVariable(scope: ReturnType<typeof context.sourceCode.getScope>, name: string): { defs: unknown[] } | null {
120+
let current: typeof scope | null = scope;
121+
while (current) {
122+
const variable = current.variables.find((v) => v.name === name);
123+
if (variable) {
124+
return variable;
125+
}
126+
current = current.upper;
127+
}
128+
return null;
129+
}
130+
},
131+
});

tests/preferActiveDoc.test.ts

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { RuleTester } from "@typescript-eslint/rule-tester";
2+
import preferActiveDocRule from "../lib/rules/preferActiveDoc.js";
3+
4+
const ruleTester = new RuleTester();
5+
6+
ruleTester.run("prefer-active-doc", preferActiveDocRule, {
7+
valid: [
8+
{
9+
name: "activeDocument is allowed",
10+
code: "activeDocument.createElement('div');",
11+
},
12+
{
13+
name: "activeWindow is allowed",
14+
code: "activeWindow.requestAnimationFrame(() => {});",
15+
},
16+
{
17+
name: "property named document on an object is allowed",
18+
code: "const obj = { document: 1 }; obj.document;",
19+
},
20+
{
21+
name: "property named window on an object is allowed",
22+
code: "const obj = { window: 1 }; obj.window;",
23+
},
24+
{
25+
name: "local variable named document is allowed",
26+
code: "const document = activeDocument; document.createElement('div');",
27+
},
28+
{
29+
name: "local variable named window is allowed",
30+
code: "const window = activeWindow; window.setTimeout(() => {}, 0);",
31+
},
32+
{
33+
name: "typeof window check is allowed",
34+
code: "if (typeof window !== 'undefined') {}",
35+
},
36+
{
37+
name: "typeof document check is allowed",
38+
code: "if (typeof document !== 'undefined') {}",
39+
},
40+
{
41+
name: "typeof globalThis check is allowed",
42+
code: "if (typeof globalThis !== 'undefined') {}",
43+
},
44+
{
45+
name: "property named global on an object is allowed",
46+
code: "const obj = { global: 1 }; obj.global;",
47+
},
48+
],
49+
invalid: [
50+
{
51+
name: "bare document reference is forbidden",
52+
code: "document.createElement('div');",
53+
output: "activeDocument.createElement('div');",
54+
errors: [{ messageId: "preferActive", data: { original: "document", replacement: "activeDocument" } }],
55+
},
56+
{
57+
name: "bare window reference is forbidden",
58+
code: "window.requestAnimationFrame(() => {});",
59+
output: "activeWindow.requestAnimationFrame(() => {});",
60+
errors: [{ messageId: "preferActive", data: { original: "window", replacement: "activeWindow" } }],
61+
},
62+
{
63+
name: "document.body is forbidden",
64+
code: "const body = document.body;",
65+
output: "const body = activeDocument.body;",
66+
errors: [{ messageId: "preferActive" }],
67+
},
68+
{
69+
name: "window.innerWidth is forbidden",
70+
code: "const width = window.innerWidth;",
71+
output: "const width = activeWindow.innerWidth;",
72+
errors: [{ messageId: "preferActive" }],
73+
},
74+
{
75+
name: "document.querySelector is forbidden",
76+
code: "document.querySelector('.my-class');",
77+
output: "activeDocument.querySelector('.my-class');",
78+
errors: [{ messageId: "preferActive" }],
79+
},
80+
{
81+
name: "document.addEventListener is forbidden",
82+
code: "document.addEventListener('click', handler);",
83+
output: "activeDocument.addEventListener('click', handler);",
84+
errors: [{ messageId: "preferActive" }],
85+
},
86+
{
87+
name: "window.setTimeout is forbidden",
88+
code: "window.setTimeout(() => {}, 100);",
89+
output: "activeWindow.setTimeout(() => {}, 100);",
90+
errors: [{ messageId: "preferActive" }],
91+
},
92+
{
93+
name: "globalThis reference is forbidden",
94+
code: "globalThis.setTimeout(() => {}, 100);",
95+
errors: [{ messageId: "avoidGlobal", data: { name: "globalThis" } }],
96+
},
97+
{
98+
name: "global reference is forbidden",
99+
code: "global.process;",
100+
errors: [{ messageId: "avoidGlobal", data: { name: "global" } }],
101+
},
102+
],
103+
});

0 commit comments

Comments
 (0)