Skip to content
Closed
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
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,37 @@ export default defineConfig([
| ------------------------------------------- | ----------------------------------------------------------------------------------- |
| [`markdown`](./docs/processors/markdown.md) | Extract fenced code blocks from the Markdown code so they can be linted separately. |

### Materializing code blocks as temp files (advanced)

By default, the Markdown processor only creates **virtual child files** for fenced code blocks (for example, `README.md/0.js` or `file.mdc/0.ts`).
For some integrations – such as typed linting setups that require real files on disk – you can optionally ask the processor to also write each code block to a deterministic temp file.

This behavior is disabled by default and can be enabled from your `eslint.config.*` file:

```js
// eslint.config.js
import { defineConfig } from "eslint/config";
import markdown, { setMarkdownProcessorOptions } from "@eslint/markdown";

// Configure the Markdown processor before running ESLint.
setMarkdownProcessorOptions({
materializeCodeBlocks: true,
// Optional: override the base directory for temp files.
// If omitted, a subdirectory of the OS temp directory is used.
tempDir: ".eslint-markdown-temp",
});

export default defineConfig([
{
files: ["**/*.md"],
plugins: {
markdown,
},
processor: "markdown/markdown",
},
]);
```

## Migration from `eslint-plugin-markdown`

See [Migration](./docs/migration.md#from-eslint-plugin-markdown).
Expand Down
37 changes: 37 additions & 0 deletions docs/processors/markdown.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,43 @@ export default [
];
```

### Materializing code blocks as real files (optional)

By default, the Markdown processor treats each fenced code block as a **virtual child file** (for example, `README.md/0.js` or `file.mdc/0.ts`) and does not write any additional files to disk.
In some setups – especially when integrating with tools that require **real files on disk** (such as TypeScript project services) – it can be useful to also materialize these code blocks as temp files.

You can opt into this behavior from your `eslint.config.*` file by calling the exported `setMarkdownProcessorOptions` function **before** running ESLint:

```js
// eslint.config.js
import markdown, { setMarkdownProcessorOptions } from "@eslint/markdown";

setMarkdownProcessorOptions({
materializeCodeBlocks: true,
// Optional: override the base directory for temp files.
// If omitted, a subdirectory of the operating system temp dir is used.
tempDir: ".eslint-markdown-temp",
});

export default [
{
plugins: {
markdown,
},
files: ["**/*.md"],
processor: "markdown/markdown",
},
];
```

When `tempDir` is **not** provided, the processor uses a subdirectory of the operating system temp directory (for example, `os.tmpdir()/eslint-markdown`) as the base for materialized files.
This is convenient for ad‑hoc tooling, but for TypeScript project services and other long‑lived setups we **strongly recommend** using a project‑local directory (such as `.eslint-markdown-temp`) and:

- including it explicitly in your `tsconfig` `include`/`files` if needed, and
- adding it to your VCS ignore list (for example, `.gitignore`).

Using absolute OS‑specific temp paths (for example, `C:/Users/<user>/AppData/Local/Temp/eslint-markdown/**`) in `tsconfig` is discouraged, because such paths are brittle across machines, users, and CI environments.

## Frequently-Disabled Rules

Some rules that catch mistakes in regular code are less helpful in documentation.
Expand Down
4 changes: 2 additions & 2 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
// Imports
//-----------------------------------------------------------------------------

import { processor } from "./processor.js";
import { processor, setMarkdownProcessorOptions } from "./processor.js";
import { MarkdownLanguage } from "./language/markdown-language.js";
import { MarkdownSourceCode } from "./language/markdown-source-code.js";
import recommendedRules from "./build/recommended-config.js";
Expand Down Expand Up @@ -133,6 +133,6 @@ const plugin = {
recommendedPlugins.markdown = processorPlugins.markdown = plugin;

export default plugin;
export { MarkdownSourceCode };
export { MarkdownSourceCode, setMarkdownProcessorOptions };
export * from "./language/markdown-language.js";
export * from "./types.js";
167 changes: 164 additions & 3 deletions src/processor.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
// Imports
//-----------------------------------------------------------------------------

import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { fromMarkdown } from "mdast-util-from-markdown";

//-----------------------------------------------------------------------------
Expand All @@ -22,6 +25,16 @@ import { fromMarkdown } from "mdast-util-from-markdown";
* @typedef {AST.Range} Range
*/

/**
* @typedef {Object} MarkdownProcessorOptions
* @property {boolean} [materializeCodeBlocks] When `true`, fenced code blocks
* will be written to real temp files on disk in addition to being returned to
* ESLint as virtual children. Defaults to `false`.
* @property {string} [tempDir] Optional base directory for materialized code
* blocks. When not provided, defaults to a subdirectory of the operating
* system temp directory (via `os.tmpdir()`).
*/

//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------
Expand All @@ -34,6 +47,91 @@ const SUPPORTS_AUTOFIX = true;

const BOM = "\uFEFF";

const DEFAULT_TEMP_DIR_NAME = "eslint-markdown";

/**
* Runtime options for the processor.
* These are intentionally kept module-local and configurable via
* `setMarkdownProcessorOptions()` to avoid coupling to ESLint's configuration
* model, which does not currently pass options into processors.
* @type {Required<Pick<MarkdownProcessorOptions, "materializeCodeBlocks">> & Pick<MarkdownProcessorOptions, "tempDir">}
*/
const processorOptions = {
materializeCodeBlocks: false,
tempDir: undefined,
};

/**
* Gets the base directory for materialized code blocks.
* @returns {string} The absolute base directory path.
*/
function getMaterializeBaseDir() {
const baseDir =
processorOptions.tempDir ||
path.join(os.tmpdir(), DEFAULT_TEMP_DIR_NAME);

return path.resolve(baseDir);
}

/**
* Computes a deterministic on-disk path for a materialized code block.
* Layout:
* <baseDir>/<sanitizedMarkdownPath>/<index>_<virtualFilename>
* @param {string|undefined} markdownFilename The Markdown file's filename as seen by ESLint.
* @param {number} index The zero-based index of the code block within the Markdown file.
* @param {string} virtualFilename The virtual filename returned to ESLint (e.g., `0.ts` or `src/example.ts`).
* @returns {string} Absolute path for the materialized temp file.
*/
function getMaterializedFilePath(markdownFilename, index, virtualFilename) {
const baseDir = getMaterializeBaseDir();

// Start with either the provided filename or a synthetic bucket.
let relativeMdPath = markdownFilename || "__anonymous__";

// Strip Windows drive letters and leading separators to keep the path relative.
relativeMdPath = relativeMdPath.replace(/^[a-zA-Z]:[\\/]/u, "");
relativeMdPath = relativeMdPath.replace(/^[\\/]/u, "");

// Replace any remaining colon characters to avoid issues on Windows.
relativeMdPath = relativeMdPath.replace(/:/gu, "_");

// Ensure we always have some directory segment.
if (!relativeMdPath) {
relativeMdPath = "__anonymous__";
}

const markdownDir = path.join(baseDir, relativeMdPath);

// Use the virtual filename for human-friendly diagnostics, but sanitize
// any path separators so that everything stays under `markdownDir`.
const safeVirtualName = virtualFilename.replace(/[\\/]/gu, "_");
const materializedName = `${index}_${safeVirtualName}`;

return path.join(markdownDir, materializedName);
}

/**
* Writes a code block to a materialized temp file on disk.
* @param {string|undefined} markdownFilename The Markdown file's filename as seen by ESLint.
* @param {number} index The zero-based index of the code block within the Markdown file.
* @param {string} virtualFilename The virtual filename returned to ESLint.
* @param {string} text The block text to write.
* @returns {string} The absolute path to the materialized file.
*/
function materializeCodeBlock(markdownFilename, index, virtualFilename, text) {
const filePath = getMaterializedFilePath(
markdownFilename,
index,
virtualFilename,
);

const dir = path.dirname(filePath);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(filePath, text, "utf8");

return filePath;
}

/**
* @type {Map<string, Block[]>}
*/
Expand Down Expand Up @@ -264,7 +362,7 @@ const languageToFileExtension = {
* Extracts lintable code blocks from Markdown text.
* @param {string} sourceText The text of the file.
* @param {string} filename The filename of the file.
* @returns {Array<{ filename: string, text: string }>} Source code blocks to lint.
* @returns {Array<{ filename: string, text: string, physicalFilename?: string }>} Source code blocks to lint.
*/
function preprocess(sourceText, filename) {
const text = sourceText.startsWith(BOM) ? sourceText.slice(1) : sourceText;
Expand Down Expand Up @@ -342,9 +440,32 @@ function preprocess(sourceText, filename) {
? languageToFileExtension[language]
: language;

const virtualFilename =
fileNameFromMeta(block) ?? `${index}.${fileExtension}`;
const blockText = [...block.comments, block.value, ""].join("\n");

/** @type {string | undefined} */
let physicalFilename;

if (processorOptions.materializeCodeBlocks) {
/*
* Best-effort: if materialization fails, it's better to surface the
* underlying I/O problem (misconfigured permissions, invalid
* tempDir, etc.) than to silently continue in a half-configured
* state.
*/
physicalFilename = materializeCodeBlock(
filename,
index,
virtualFilename,
blockText,
);
}

return {
filename: fileNameFromMeta(block) ?? `${index}.${fileExtension}`,
text: [...block.comments, block.value, ""].join("\n"),
filename: virtualFilename,
text: blockText,
...(physicalFilename && { physicalFilename }),
};
});
}
Expand Down Expand Up @@ -466,6 +587,46 @@ function postprocess(messages, filename) {
});
}

/**
* Updates the runtime options used by the Markdown processor.
* This function is intentionally side-effectful and should be called from
* your `eslint.config.*` file before running ESLint. It is opt-in and does
* not change behavior unless explicitly configured.
* @param {MarkdownProcessorOptions} [options] The options to apply.
* @throws {Error} When invalid option values are provided.
* @returns {void}
*/
export function setMarkdownProcessorOptions(options = {}) {
if (Object.hasOwn(options, "materializeCodeBlocks")) {
const { materializeCodeBlocks } = options;

if (
typeof materializeCodeBlocks !== "boolean" &&
typeof materializeCodeBlocks !== "undefined"
) {
throw new Error(
"Invalid markdown processor option: `materializeCodeBlocks` must be a boolean.",
);
}

if (typeof materializeCodeBlocks === "boolean") {
processorOptions.materializeCodeBlocks = materializeCodeBlocks;
}
}

if (Object.hasOwn(options, "tempDir")) {
const { tempDir } = options;

if (tempDir !== undefined && typeof tempDir !== "string") {
throw new Error(
"Invalid markdown processor option: `tempDir` must be a string when provided.",
);
}

processorOptions.tempDir = tempDir;
}
}

export const processor = {
meta: {
name: "@eslint/markdown/markdown",
Expand Down
62 changes: 61 additions & 1 deletion tests/processor.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@

import assert from "node:assert";
import path from "node:path";
import { processor } from "../src/processor.js";
import fs from "node:fs";
import { processor, setMarkdownProcessorOptions } from "../src/processor.js";
import { fileURLToPath } from "node:url";

const __filename = fileURLToPath(import.meta.url);
Expand Down Expand Up @@ -1101,4 +1101,64 @@ describe("processor", () => {
assert.strictEqual(processor.supportsAutofix, true);
});
});

describe("materializeCodeBlocks option", () => {
const tempRoot = path.resolve(__dirname, ".tmp-markdown-processor");

afterEach(() => {
// Reset processor options to defaults so other tests aren't affected.
setMarkdownProcessorOptions({
materializeCodeBlocks: false,
tempDir: undefined,
});

fs.rmSync(tempRoot, { recursive: true, force: true });
});

it("should not write temp files when materializeCodeBlocks is false", () => {
const tempDir = path.join(tempRoot, "off");

setMarkdownProcessorOptions({
materializeCodeBlocks: false,
tempDir,
});

const code = ["```ts", "const answer: number = 42;", "```"].join(
"\n",
);

processor.preprocess(code, "docs/example.md");

assert.strictEqual(fs.existsSync(tempDir), false);
});

it("should write temp files when materializeCodeBlocks is true", () => {
const tempDir = path.join(tempRoot, "on");

setMarkdownProcessorOptions({
materializeCodeBlocks: true,
tempDir,
});

const code = ["```ts", "const answer: number = 42;", "```"].join(
"\n",
);

const blocks = processor.preprocess(code, "docs/example.md");

// Derived from getMaterializedFilePath(): <tempDir>/<mdPath>/<index>_<virtualFilename>
const expectedPath = path.join(
tempDir,
"docs/example.md",
"0_0.ts",
);

assert.strictEqual(blocks.length, 1);
assert.strictEqual(fs.existsSync(expectedPath), true);
assert.strictEqual(
fs.readFileSync(expectedPath, "utf8"),
blocks[0].text,
);
});
});
});