Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,27 @@ export default defineConfig([
]);
```

### Code that only runs on desktop

`no-nodejs-modules` reports because Node APIs are unavailable on mobile. Where that does not apply —
a desktop-only plugin, or code that never runs inside Obsidian at all — say so with the rule's
option rather than switching it off, so guarded and unguarded imports are still distinguished
everywhere else:

```js
{
rules: {
"obsidianmd/no-nodejs-modules": ["warn", { isDesktopOnly: true }],
},
}
Comment on lines +178 to +182
```

The option defaults to the `isDesktopOnly` value of a `manifest.json` in the working directory, so a
desktop-only plugin needs no configuration at all. It exists for the case where there is no manifest
to read it from — a library, tooling, or a standalone CLI in the same repo — which previously meant
adding a `manifest.json` that served no other purpose. Note that this rule is listed in
`eslint-comments/no-restricted-disable`, so a disable comment is not an available escape.

ESLint does not support disabling all rules from a plugin with a single glob like `"obsidianmd/*": "off"`. You can set each rule to `"off"` individually, or use `ruleConfigs` to generate the overrides programmatically:

```js
Expand Down
31 changes: 31 additions & 0 deletions docs/rules/no-nodejs-modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,34 @@
⚠️ This rule _warns_ in the following configs: ✅ `recommended`, 🇬🇧 `recommendedWithLocalesEn`.

<!-- end auto-generated rule header -->

## Options

<!-- begin auto-generated rule options list -->

| Name | Description | Type |
| :-------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | :------ |
| `isDesktopOnly` | Whether the code being linted only ever runs on desktop, which makes Node.js APIs available. Defaults to the isDesktopOnly value of a manifest.json in the working directory, so a repo with no manifest -- a library, tooling, a standalone CLI -- can say so without shipping a fake one. | Boolean |

<!-- end auto-generated rule options list -->

`isDesktopOnly` defaults to the `isDesktopOnly` value of a `manifest.json` in the
working directory. A repo that is not a plugin — a library, tooling, a
standalone CLI in the same repo — has no manifest to carry that flag, and used to
need a fake one purely to keep this rule quiet. Set the option instead:

```js
{
rules: {
"obsidianmd/no-nodejs-modules": ["warn", { isDesktopOnly: true }],
},
}
Comment on lines +25 to +29
```

Only a literal `true` in that manifest counts. The file belongs to the repo
being linted, so its `isDesktopOnly` is whatever JSON it holds: a non-boolean
value -- `"false"`, `1`, `[]` -- is read as *not* desktop-only and the rule
keeps reporting, rather than being turned off by a malformed field.

This rule is listed in `eslint-comments/no-restricted-disable`, so a disable
comment is not an available escape.
5 changes: 3 additions & 2 deletions lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,8 +212,9 @@ const flatRecommendedGeneralRules: RulesConfig = {
],
"@microsoft/sdl/no-document-write": "warn",
"@microsoft/sdl/no-inner-html": "warn",
"obsidianmd/no-nodejs-modules":
manifest && manifest.isDesktopOnly ? "off" : "warn",
// The rule resolves the desktop-only signal itself, defaulting to the
// manifest, so the severity no longer has to be computed here.
"obsidianmd/no-nodejs-modules": "warn",
"import/no-extraneous-dependencies": "warn",
"obsidianmd/rule-custom-message": [
"error",
Expand Down
25 changes: 25 additions & 0 deletions lib/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,16 @@ export function getManifest(): PluginManifest | null {
return cachedManifest;
}

// A library, a tooling repo or a standalone CLI legitimately has no
// manifest, so its absence is not an error and must not print anything --
// it used to write to console.error on every lint run in such a repo. A
// manifest that exists but does not parse is a real defect and still
// reports.
if (!fs.existsSync("manifest.json")) {
cachedManifest = null;
return cachedManifest;
}

try {
const data = fs.readFileSync("manifest.json", "utf8");
cachedManifest = JSON.parse(data);
Expand All @@ -18,3 +28,18 @@ export function getManifest(): PluginManifest | null {
return cachedManifest;
}
}

/**
* Whether a manifest says its plugin is desktop-only.
*
* A manifest.json belongs to the repo being linted, so `isDesktopOnly` is a
* `boolean` only to TypeScript -- at runtime it is whatever the file holds.
* `"false"`, `1`, `[]` and `{}` are all truthy, so a truthiness test lets a
* malformed field turn a rule off for the very repo that shipped it. Only a
* real `true` counts.
*/
export function isManifestDesktopOnly(
manifest: PluginManifest | null = getManifest(),
): boolean {
return (manifest as { isDesktopOnly?: unknown } | null)?.isDesktopOnly === true;
}
35 changes: 31 additions & 4 deletions lib/rules/noNodejsModules.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,50 @@
import { AST_NODE_TYPES } from "@typescript-eslint/utils";
import type { TSESTree } from "@typescript-eslint/utils";
import { isBuiltin } from "node:module";
import { isManifestDesktopOnly } from "../manifest.js";
import { docsUrl, ruleCreator } from "../ruleCreator.js";

export default ruleCreator({
interface NoNodejsModulesOptions {
isDesktopOnly?: boolean;
}

export default ruleCreator<[NoNodejsModulesOptions?], "noNodejs">({
meta: {
type: "problem",
docs: {
description: "Disallow importing Node.js built-in modules unless guarded by Platform.isDesktop",
url: docsUrl("no-nodejs-modules"),
},
schema: [],
schema: [
{
type: "object",
properties: {
isDesktopOnly: {
type: "boolean",
description:
"Whether the code being linted only ever runs on desktop, which makes Node.js APIs available. Defaults to the isDesktopOnly value of a manifest.json in the working directory, so a repo with no manifest -- a library, tooling, a standalone CLI -- can say so without shipping a fake one.",
},
},
additionalProperties: false,
},
],
messages: {
noNodejs:
"Do not import Node.js built-in module \"{{module}}\". Node.js APIs are not available on mobile. Use a dynamic import() or require() guarded by Platform.isDesktop instead.",
},
},
defaultOptions: [],
create(context) {
// Must be [{}] rather than []: applyDefault iterates the defaults array, so
// an empty one discards the user's options instead of merging them.
defaultOptions: [{}],
create(context, [options]) {
// The rule is listed in eslint-comments/no-restricted-disable, so a
// disable comment is not an available escape; this option is.
const isDesktopOnly =
options?.isDesktopOnly ?? isManifestDesktopOnly();
if (isDesktopOnly) {
return {};
}

return {
// Static import declarations can never be guarded at runtime
ImportDeclaration(node: TSESTree.ImportDeclaration) {
Expand Down
46 changes: 46 additions & 0 deletions tests/manifest.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import assert from "node:assert";
import { describe, it } from "mocha";
import type { PluginManifest } from "../types/manifest.js";
import { isManifestDesktopOnly } from "../lib/manifest.js";

// A manifest.json is supplied by the repo being linted -- in the community
// scanner, by whoever submitted the plugin -- and reaches these rules through
// JSON.parse, so isDesktopOnly is a boolean only to TypeScript. Every value
// below is truthy at runtime, and a truthiness test would read each of them as
// desktop-only, which turns no-nodejs-modules off for that repo.
Comment on lines +8 to +10
const NOT_DESKTOP_ONLY: unknown[] = [
"false",
"true",
"no",
1,
[],
{},
false,
0,
null,
undefined,
];

function manifestWith(isDesktopOnly: unknown): PluginManifest {
return { isDesktopOnly } as unknown as PluginManifest;
}

describe("isManifestDesktopOnly", () => {
it("is true only for a real boolean true", () => {
assert.strictEqual(isManifestDesktopOnly(manifestWith(true)), true);
});

for (const value of NOT_DESKTOP_ONLY) {
it(`is false for ${JSON.stringify(value) ?? String(value)}`, () => {
assert.strictEqual(isManifestDesktopOnly(manifestWith(value)), false);
});
}

it("is false for a manifest with no isDesktopOnly at all", () => {
assert.strictEqual(isManifestDesktopOnly({} as PluginManifest), false);
});

it("is false when there is no manifest", () => {
assert.strictEqual(isManifestDesktopOnly(null), false);
});
});
18 changes: 18 additions & 0 deletions tests/noNodejsModules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,18 @@ ruleTester.run("no-nodejs-modules", noNodejsModules, {
name: "importing a third-party module is allowed",
code: "import _ from 'lodash';",
},
{
// A library, tooling repo or standalone CLI has no manifest to
// carry isDesktopOnly, and used to need a fake one to say so.
name: "static import is allowed when isDesktopOnly is set",
code: "import fs from 'node:fs';",
options: [{ isDesktopOnly: true }],
},
{
name: "unguarded dynamic import() is allowed when isDesktopOnly is set",
code: "const fs = await import('fs');",
options: [{ isDesktopOnly: true }],
},
{
name: "dynamic import() inside if (Platform.isDesktop) is allowed",
code: "if (Platform.isDesktop) { const fs = await import('fs'); }",
Expand Down Expand Up @@ -262,6 +274,12 @@ ruleTester.run("no-nodejs-modules", noNodejsModules, {
`,
errors: [{ messageId: "noNodejs" }],
},
{
name: "explicitly setting isDesktopOnly false still reports",
code: "import fs from 'node:fs';",
options: [{ isDesktopOnly: false }],
errors: [{ messageId: "noNodejs" }],
},
{
name: "dynamic import() with Platform check but no early exit is forbidden",
code: `
Expand Down
Loading