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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ The `parserOptions` block is required because the recommended config includes ty

> **Note:** You do not need to separately add `eslint.configs.recommended` or `tseslint.configs.recommended` — both are already included in the recommended config.

The recommended config also lints files that are not source, matching `package.json` and
`LICENSE` by name. **Run ESLint from the project root**: a lint script scoped to your sources —
`eslint src` — never visits them, and their checks silently do nothing. See
[Files linted beyond your source](docs/configuration.md#files-linted-beyond-your-source).

For advanced usage — layering stricter typescript-eslint configs, ignoring files, disabling rules for non-plugin code, and troubleshooting common errors — see the [configuration guide](docs/configuration.md).

## Configurations
Expand Down
60 changes: 57 additions & 3 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,44 @@ The recommended config is an array of flat config objects that sets up:
- **Third-party plugins**: `@microsoft/eslint-plugin-sdl`, `eslint-plugin-import`, `eslint-plugin-no-unsanitized`, `eslint-plugin-depend`, `@eslint-community/eslint-plugin-eslint-comments`
- **Obsidian globals** (`activeDocument`, `activeWindow`, `createEl`, etc.)
- **`package.json` linting** via `eslint-plugin-depend` (ban common micro-utilities)
- **`LICENSE` linting** via `validate-license`, read with the plugin's `obsidianmd/plain-text` language

Because of this, you do **not** need to separately add `eslint.configs.recommended` or `tseslint.configs.recommended` — they are already included.

### Files linted beyond your source

The recommended config lints more than `.ts` and `.js`. It matches `package.json` and `LICENSE` by
name, each read with its own language, so **ESLint has to be pointed at the project root for those
checks to run at all**. A lint script scoped to your sources — `eslint src` — will never visit
them, and the rules covering them silently do nothing:

```jsonc
// package.json
"scripts": {
"lint": "eslint .", // package.json and LICENSE are checked
"lint:src": "eslint src" // they are not
}
```

`LICENSE` is matched exactly, alongside `LICENSE.md` and `LICENSE.txt`. A licence file under any
other name is not linted; point `validate-license` at it with your own `files` entry, using the
language the plugin contributes:

```js
{
files: ["COPYING"],
language: "obsidianmd/plain-text",
rules: { "obsidianmd/validate-license": "warn" },
}
```

`plain-text` is a **language**, not a parser, and that distinction matters for more than tidiness.
`languageOptions.parser` is merged by key, so a config object that sets a parser **without
restricting its `files`** replaces it for every file — including `LICENSE`, which would then reach
(say) the TypeScript parser and fail with *"was not found by the project service because the
extension for the file (``) is non-standard"*. A `language` is only replaced by another `language`,
so that cannot happen. The same reasoning is why `package.json` uses `language: "json/json"`.

## Using alongside stricter typescript-eslint configs

If you want stricter rules than what the recommended config provides (e.g., `strictTypeChecked`, `stylisticTypeChecked`), layer them after the recommended spread:
Expand Down Expand Up @@ -288,7 +323,17 @@ A few things to keep in mind with this approach:

- **Obsidian globals** must be declared manually. The recommended config does this for you; here you need to add them yourself. The list above covers the most common ones. `DomElementInfo`, `SvgElementInfo`, `isBoolean`, `nextFrame`, and `ready` are also available.
- **Third-party plugins** bundled by the recommended config (`@microsoft/eslint-plugin-sdl`, `eslint-plugin-import`, `eslint-plugin-no-unsanitized`, `eslint-plugin-depend`, `eslint-plugin-eslint-comments`) are not included. Add them separately if you want them.
- **`package.json` and `manifest.json` linting** (`validate-manifest`, `validate-license`, `depend/ban-dependencies`) is not set up. The `validate-manifest` and `validate-license` rules will work on `.json` files only if you have a JSON parser configured.
- **`package.json` and `manifest.json` linting** (`validate-manifest`, `depend/ban-dependencies`) is not set up. The `validate-manifest` rule will work on `.json` files only if you have a JSON parser configured.
- **`LICENSE` linting** (`validate-license`) is not set up either, and is not covered by `ruleConfigs` — those presets only carry rules that apply to source files. `validate-license` needs its own config block, because `LICENSE` matches no source glob and needs its own language:

```js
{
files: ["LICENSE", "LICENSE.md", "LICENSE.txt"],
language: "obsidianmd/plain-text",
plugins: { obsidianmd },
rules: { "obsidianmd/validate-license": "warn" },
}
```

## Community plugin scanner configuration

Expand Down Expand Up @@ -384,16 +429,25 @@ export default defineConfig([
"@typescript-eslint/no-base-to-string": "off",
"import/no-unresolved": "off",

// Scanner handles these separately
// Scanner handles this separately
"obsidianmd/validate-manifest": "off",
"obsidianmd/validate-license": "off",

// Old plugins should not change their command ids
"obsidianmd/commands/no-command-in-command-id": "off",
"obsidianmd/commands/no-plugin-id-in-command-id": "off",
},
},

{
// Scanner handles this separately. It must be switched off under the glob
// the rule actually runs on -- LICENSE matches no source glob, so an "off"
// in the block above would not reach it.
files: ["LICENSE", "LICENSE.md", "LICENSE.txt"],
rules: {
"obsidianmd/validate-license": "off",
},
},

globalIgnores([
"node_modules",
"dist",
Expand Down
6 changes: 6 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,10 @@ export default [
...eslintPluginPlugin.configs["tests-recommended"],
files: ["tests/**/*.ts"],
},
// This repo is the plugin, not an Obsidian plugin. Its LICENSE is Dynalist's
// own, so the rule is correct to fire here and the finding is unactionable.
{
files: ["LICENSE"],
rules: { "obsidianmd/validate-license": "off" },
},
];
7 changes: 7 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { type Linter, type Rule } from "eslint";

declare module 'eslint-plugin-obsidianmd' {
/**
* Languages contributed by this plugin. `plain-text` exposes each line of a text file as a
* `Line` node and backs `validate-license`; reference it as `language: "obsidianmd/plain-text"`.
*/
export const languages: {
[key: string]: unknown;
};
export const meta: {
name: string;
version: string;
Expand Down
26 changes: 25 additions & 1 deletion lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import ruleCustomMessage from "./rules/ruleCustomMessage.js";
import noNodejsModules from "./rules/noNodejsModules.js";
import noUnsupportedApi from "./rules/noUnsupportedApi.js";
import { getManifest } from "./manifest.js";
import { PlainTextLanguage } from "./plainTextLanguage.js";
import { ui } from "./rules/ui/index.js";

// --- Import plugins and configs for the recommended config ---
Expand Down Expand Up @@ -68,6 +69,13 @@ const plugin = {
name: packageJson.name,
version: packageJson.version,
},
// A language, not a parser: `languageOptions.parser` is merged by key, so
// any later config object without a `files` restriction replaces it, and
// LICENSE then reaches whatever parser that block names. `language` is only
// replaced by another `language`.
languages: {
"plain-text": PlainTextLanguage
},
rules: {
"commands/no-command-in-command-id": commands.noCommandInCommandId,
"commands/no-command-in-command-name": commands.noCommandInCommandName,
Expand Down Expand Up @@ -171,7 +179,9 @@ const recommendedPluginRulesConfigBase: RulesConfig = {
"obsidianmd/regex-lookbehind": "error",
"obsidianmd/sample-names": "error",
"obsidianmd/validate-manifest": "warn",
"obsidianmd/validate-license": ["warn"],
// validate-license is NOT here: it lints LICENSE, which cannot match the
// JS/TS globs this block is spread into. It gets its own file-scoped block
// below.
"obsidianmd/ui/sentence-case": ["warn", { enforceCamelCaseLower: true }],
}

Expand Down Expand Up @@ -290,6 +300,20 @@ const flatRecommendedConfig: Config[] = defineConfig([
]
}
},
// LICENSE has no extension and is not code, so it needs both an explicit
// glob and a language that can read it.
{
files: ['LICENSE', 'LICENSE.md', 'LICENSE.txt'],
language: 'obsidianmd/plain-text',
extends: [tseslint.configs.disableTypeChecked as Config],
plugins: {
obsidianmd: plugin
},
rules: {
"no-irregular-whitespace": "off",
"obsidianmd/validate-license": "warn"
}
},
{
files: ['**/*.{ts,cts,mts,tsx,js,cjs,mjs,jsx}'],
rules: {
Expand Down
181 changes: 181 additions & 0 deletions lib/plainTextLanguage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import type {
File,
Language,
LanguageContext,
OkParseResult,
ParseResult,
RuleVisitor,
SourceLocation,
SourceRange,
TraversalStep,
} from "@eslint/core";
import { TextSourceCodeBase, VisitNodeStep } from "@eslint/plugin-kit";

/** A single line of a plain text file. */
export interface PlainTextLineNode {
type: "Line";
/** The text of the line, without its terminator. */
value: string;
loc: SourceLocation;
range: SourceRange;
}

/** The root node of a plain text file. */
export interface PlainTextDocumentNode {
type: "Document";
lines: PlainTextLineNode[];
loc: SourceLocation;
range: SourceRange;
}

export type PlainTextNode = PlainTextDocumentNode | PlainTextLineNode;

/** Plain text has nothing to configure. */
export type PlainTextLanguageOptions = Record<string, never>;

export interface PlainTextRuleVisitor extends RuleVisitor {
Document?(node: PlainTextDocumentNode): void;
Line?(node: PlainTextLineNode, parent?: PlainTextDocumentNode): void;
"Document:exit"?(node: PlainTextDocumentNode): void;
"Line:exit"?(node: PlainTextLineNode, parent?: PlainTextDocumentNode): void;
}

const LINE_ENDING_PATTERN = /\r\n|[\r\n]/u;

export class PlainTextSourceCode extends TextSourceCodeBase<{
LangOptions: PlainTextLanguageOptions;
RootNode: PlainTextDocumentNode;
SyntaxElementWithLoc: PlainTextNode;
ConfigNode: never;
}> {
override ast: PlainTextDocumentNode;

#steps: TraversalStep[] | undefined;

constructor({ text, ast }: { text: string; ast: PlainTextDocumentNode }) {
super({ text, ast, lineEndingPattern: LINE_ENDING_PATTERN });
this.ast = ast;
}

// The AST is only two levels deep, so a node's parent is the document
// unless the node is the document itself.
override getParent(node: PlainTextNode): PlainTextNode | undefined {
return node.type === "Line" ? this.ast : undefined;
}

override traverse(): Iterable<TraversalStep> {
// The AST does not mutate, so the steps can be cached.
if (this.#steps) {
return this.#steps.values();
}

const steps: TraversalStep[] = (this.#steps = []);

steps.push(
new VisitNodeStep({
target: this.ast,
phase: 1,
args: [this.ast],
}),
);

for (const line of this.ast.lines) {
steps.push(
new VisitNodeStep({ target: line, phase: 1, args: [line, this.ast] }),
new VisitNodeStep({ target: line, phase: 2, args: [line, this.ast] }),
);
}

steps.push(
new VisitNodeStep({
target: this.ast,
phase: 2,
args: [this.ast],
}),
);

return steps;
}
}

/**
* An ESLint language that exposes each line of a text file as a `Line` node.
*
* This is a language rather than a parser on purpose. A parser is set through
* `languageOptions.parser`, which ESLint merges by key, so any later config
* object without a `files` restriction replaces it -- and a LICENSE file then
* reaches, say, the TypeScript parser, which fails on an extensionless file.
* A `language` is only replaced by another `language`, so the LICENSE block
* cannot be clobbered by an unrelated `languageOptions` entry.
*/
export const PlainTextLanguage: Language<{
LangOptions: PlainTextLanguageOptions;
Code: PlainTextSourceCode;
RootNode: PlainTextDocumentNode;
Node: PlainTextNode;
}> = {
fileType: "text",
lineStart: 1,
// 0 so that reported columns stay 1-based, as they are for source files.
columnStart: 0,
nodeTypeKey: "type",
visitorKeys: {
Document: ["lines"],
Line: [],
},

// Plain text takes no options. Anything else present on languageOptions --
// a stray `parserOptions` from a config block that does not restrict its
// `files` -- is simply not ours to validate, and must not be an error.
validateLanguageOptions(): void {
// no options to validate
},

parse(file: File): ParseResult<PlainTextDocumentNode> {
const text = String(file.body);
const lines: PlainTextLineNode[] = [];

let index = 0;
let lineNumber = 1;
for (const line of text.split(LINE_ENDING_PATTERN)) {
lines.push({
type: "Line",
value: line,
range: [index, index + line.length],
loc: {
start: { line: lineNumber, column: 0 },
end: { line: lineNumber, column: line.length },
},
});
// The terminator may be 1 or 2 characters, so recover the offset
// from the source text rather than assuming "\n".
const terminator = text.slice(index + line.length).match(/^\r\n|^[\r\n]/u);
index += line.length + (terminator ? terminator[0].length : 0);
lineNumber++;
}

return {
ok: true,
ast: {
type: "Document",
lines,
range: [0, text.length],
loc: {
start: { line: 1, column: 0 },
end: lines[lines.length - 1]?.loc.end ?? { line: 1, column: 0 },
},
},
};
},

createSourceCode(
file: File,
parseResult: OkParseResult<PlainTextDocumentNode>,
_context: LanguageContext<PlainTextLanguageOptions>,
): PlainTextSourceCode {
return new PlainTextSourceCode({
text: String(file.body),
ast: parseResult.ast,
});
},
};
Loading
Loading