first commit

This commit is contained in:
2026-08-06 12:00:39 +10:00
commit 91221f5a8e
3259 changed files with 569069 additions and 0 deletions
@@ -0,0 +1,20 @@
export default rules;
declare const rules: {
readonly "markdown/fenced-code-language": "error";
readonly "markdown/heading-increment": "error";
readonly "markdown/no-duplicate-definitions": "error";
readonly "markdown/no-empty-definitions": "error";
readonly "markdown/no-empty-images": "error";
readonly "markdown/no-empty-links": "error";
readonly "markdown/no-invalid-label-refs": "error";
readonly "markdown/no-missing-atx-heading-space": "error";
readonly "markdown/no-missing-label-refs": "error";
readonly "markdown/no-missing-link-fragments": "error";
readonly "markdown/no-multiple-h1": "error";
readonly "markdown/no-reference-like-urls": "error";
readonly "markdown/no-reversed-media-syntax": "error";
readonly "markdown/no-space-in-emphasis": "error";
readonly "markdown/no-unused-definitions": "error";
readonly "markdown/require-alt-text": "error";
readonly "markdown/table-column-count": "error";
};
@@ -0,0 +1,20 @@
const rules = /** @type {const} */ ({
"markdown/fenced-code-language": "error",
"markdown/heading-increment": "error",
"markdown/no-duplicate-definitions": "error",
"markdown/no-empty-definitions": "error",
"markdown/no-empty-images": "error",
"markdown/no-empty-links": "error",
"markdown/no-invalid-label-refs": "error",
"markdown/no-missing-atx-heading-space": "error",
"markdown/no-missing-label-refs": "error",
"markdown/no-missing-link-fragments": "error",
"markdown/no-multiple-h1": "error",
"markdown/no-reference-like-urls": "error",
"markdown/no-reversed-media-syntax": "error",
"markdown/no-space-in-emphasis": "error",
"markdown/no-unused-definitions": "error",
"markdown/require-alt-text": "error",
"markdown/table-column-count": "error"
});
export default rules;
+108
View File
@@ -0,0 +1,108 @@
declare const _default: {
"fenced-code-language": {
meta: typeof rule0.meta;
create: (context: unknown) => any;
};
"fenced-code-meta": {
meta: typeof rule1.meta;
create: (context: unknown) => any;
};
"heading-increment": {
meta: typeof rule2.meta;
create: (context: unknown) => any;
};
"no-bare-urls": {
meta: typeof rule3.meta;
create: (context: unknown) => any;
};
"no-duplicate-definitions": {
meta: typeof rule4.meta;
create: (context: unknown) => any;
};
"no-duplicate-headings": {
meta: typeof rule5.meta;
create: (context: unknown) => any;
};
"no-empty-definitions": {
meta: typeof rule6.meta;
create: (context: unknown) => any;
};
"no-empty-images": {
meta: typeof rule7.meta;
create: (context: unknown) => any;
};
"no-empty-links": {
meta: typeof rule8.meta;
create: (context: unknown) => any;
};
"no-html": {
meta: typeof rule9.meta;
create: (context: unknown) => any;
};
"no-invalid-label-refs": {
meta: typeof rule10.meta;
create: (context: unknown) => any;
};
"no-missing-atx-heading-space": {
meta: typeof rule11.meta;
create: (context: unknown) => any;
};
"no-missing-label-refs": {
meta: typeof rule12.meta;
create: (context: unknown) => any;
};
"no-missing-link-fragments": {
meta: typeof rule13.meta;
create: (context: unknown) => any;
};
"no-multiple-h1": {
meta: typeof rule14.meta;
create: (context: unknown) => any;
};
"no-reference-like-urls": {
meta: typeof rule15.meta;
create: (context: unknown) => any;
};
"no-reversed-media-syntax": {
meta: typeof rule16.meta;
create: (context: unknown) => any;
};
"no-space-in-emphasis": {
meta: typeof rule17.meta;
create: (context: unknown) => any;
};
"no-unused-definitions": {
meta: typeof rule18.meta;
create: (context: unknown) => any;
};
"require-alt-text": {
meta: typeof rule19.meta;
create: (context: unknown) => any;
};
"table-column-count": {
meta: typeof rule20.meta;
create: (context: unknown) => any;
};
};
export default _default;
import rule0 from "../rules/fenced-code-language.js";
import rule1 from "../rules/fenced-code-meta.js";
import rule2 from "../rules/heading-increment.js";
import rule3 from "../rules/no-bare-urls.js";
import rule4 from "../rules/no-duplicate-definitions.js";
import rule5 from "../rules/no-duplicate-headings.js";
import rule6 from "../rules/no-empty-definitions.js";
import rule7 from "../rules/no-empty-images.js";
import rule8 from "../rules/no-empty-links.js";
import rule9 from "../rules/no-html.js";
import rule10 from "../rules/no-invalid-label-refs.js";
import rule11 from "../rules/no-missing-atx-heading-space.js";
import rule12 from "../rules/no-missing-label-refs.js";
import rule13 from "../rules/no-missing-link-fragments.js";
import rule14 from "../rules/no-multiple-h1.js";
import rule15 from "../rules/no-reference-like-urls.js";
import rule16 from "../rules/no-reversed-media-syntax.js";
import rule17 from "../rules/no-space-in-emphasis.js";
import rule18 from "../rules/no-unused-definitions.js";
import rule19 from "../rules/require-alt-text.js";
import rule20 from "../rules/table-column-count.js";
+44
View File
@@ -0,0 +1,44 @@
import rule0 from "../rules/fenced-code-language.js";
import rule1 from "../rules/fenced-code-meta.js";
import rule2 from "../rules/heading-increment.js";
import rule3 from "../rules/no-bare-urls.js";
import rule4 from "../rules/no-duplicate-definitions.js";
import rule5 from "../rules/no-duplicate-headings.js";
import rule6 from "../rules/no-empty-definitions.js";
import rule7 from "../rules/no-empty-images.js";
import rule8 from "../rules/no-empty-links.js";
import rule9 from "../rules/no-html.js";
import rule10 from "../rules/no-invalid-label-refs.js";
import rule11 from "../rules/no-missing-atx-heading-space.js";
import rule12 from "../rules/no-missing-label-refs.js";
import rule13 from "../rules/no-missing-link-fragments.js";
import rule14 from "../rules/no-multiple-h1.js";
import rule15 from "../rules/no-reference-like-urls.js";
import rule16 from "../rules/no-reversed-media-syntax.js";
import rule17 from "../rules/no-space-in-emphasis.js";
import rule18 from "../rules/no-unused-definitions.js";
import rule19 from "../rules/require-alt-text.js";
import rule20 from "../rules/table-column-count.js";
export default {
"fenced-code-language": /** @type {{meta: typeof rule0.meta; create: (context: unknown) => any}} */ (rule0),
"fenced-code-meta": /** @type {{meta: typeof rule1.meta; create: (context: unknown) => any}} */ (rule1),
"heading-increment": /** @type {{meta: typeof rule2.meta; create: (context: unknown) => any}} */ (rule2),
"no-bare-urls": /** @type {{meta: typeof rule3.meta; create: (context: unknown) => any}} */ (rule3),
"no-duplicate-definitions": /** @type {{meta: typeof rule4.meta; create: (context: unknown) => any}} */ (rule4),
"no-duplicate-headings": /** @type {{meta: typeof rule5.meta; create: (context: unknown) => any}} */ (rule5),
"no-empty-definitions": /** @type {{meta: typeof rule6.meta; create: (context: unknown) => any}} */ (rule6),
"no-empty-images": /** @type {{meta: typeof rule7.meta; create: (context: unknown) => any}} */ (rule7),
"no-empty-links": /** @type {{meta: typeof rule8.meta; create: (context: unknown) => any}} */ (rule8),
"no-html": /** @type {{meta: typeof rule9.meta; create: (context: unknown) => any}} */ (rule9),
"no-invalid-label-refs": /** @type {{meta: typeof rule10.meta; create: (context: unknown) => any}} */ (rule10),
"no-missing-atx-heading-space": /** @type {{meta: typeof rule11.meta; create: (context: unknown) => any}} */ (rule11),
"no-missing-label-refs": /** @type {{meta: typeof rule12.meta; create: (context: unknown) => any}} */ (rule12),
"no-missing-link-fragments": /** @type {{meta: typeof rule13.meta; create: (context: unknown) => any}} */ (rule13),
"no-multiple-h1": /** @type {{meta: typeof rule14.meta; create: (context: unknown) => any}} */ (rule14),
"no-reference-like-urls": /** @type {{meta: typeof rule15.meta; create: (context: unknown) => any}} */ (rule15),
"no-reversed-media-syntax": /** @type {{meta: typeof rule16.meta; create: (context: unknown) => any}} */ (rule16),
"no-space-in-emphasis": /** @type {{meta: typeof rule17.meta; create: (context: unknown) => any}} */ (rule17),
"no-unused-definitions": /** @type {{meta: typeof rule18.meta; create: (context: unknown) => any}} */ (rule18),
"require-alt-text": /** @type {{meta: typeof rule19.meta; create: (context: unknown) => any}} */ (rule19),
"table-column-count": /** @type {{meta: typeof rule20.meta; create: (context: unknown) => any}} */ (rule20),
};
+77
View File
@@ -0,0 +1,77 @@
export default plugin;
export { MarkdownSourceCode };
export * from "./language/markdown-language.js";
export * from "./types.js";
declare namespace plugin {
export namespace meta {
let name: string;
let version: string;
}
export namespace processors {
export { processor as markdown };
}
export namespace languages {
let commonmark: MarkdownLanguage;
let gfm: MarkdownLanguage;
}
export { rules };
export let configs: {
"recommended-legacy": {
plugins: string[];
overrides: ({
files: string[];
processor: string;
parserOptions?: undefined;
rules?: undefined;
} | {
files: string[];
parserOptions: {
ecmaFeatures: {
impliedStrict: boolean;
};
};
rules: {
"eol-last": "off";
"no-undef": "off";
"no-unused-expressions": "off";
"no-unused-vars": "off";
"padded-blocks": "off";
strict: "off";
"unicode-bom": "off";
};
processor?: undefined;
})[];
};
recommended: {
name: string;
files: string[];
language: string;
plugins: {};
rules: {
readonly "markdown/fenced-code-language": "error";
readonly "markdown/heading-increment": "error";
readonly "markdown/no-duplicate-definitions": "error";
readonly "markdown/no-empty-definitions": "error";
readonly "markdown/no-empty-images": "error";
readonly "markdown/no-empty-links": "error";
readonly "markdown/no-invalid-label-refs": "error";
readonly "markdown/no-missing-atx-heading-space": "error";
readonly "markdown/no-missing-label-refs": "error";
readonly "markdown/no-missing-link-fragments": "error";
readonly "markdown/no-multiple-h1": "error";
readonly "markdown/no-reference-like-urls": "error";
readonly "markdown/no-reversed-media-syntax": "error";
readonly "markdown/no-space-in-emphasis": "error";
readonly "markdown/no-unused-definitions": "error";
readonly "markdown/require-alt-text": "error";
readonly "markdown/table-column-count": "error";
};
}[];
processor: ConfigObject[];
};
}
import { MarkdownSourceCode } from "./language/markdown-source-code.js";
import { processor } from "./processor.js";
import { MarkdownLanguage } from "./language/markdown-language.js";
import rules from "./build/rules.js";
import type { ConfigObject } from "@eslint/core";
+125
View File
@@ -0,0 +1,125 @@
/**
* @fileoverview Markdown plugin.
* @author Brandon Mills
*/
//-----------------------------------------------------------------------------
// Imports
//-----------------------------------------------------------------------------
import { processor } 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";
import rules from "./build/rules.js";
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
* @import { ConfigObject, RulesConfig } from "@eslint/core";
*/
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
/** @satisfies {RulesConfig} */
const processorRulesConfig = {
// The Markdown parser automatically trims trailing
// newlines from code blocks.
"eol-last": "off",
// In code snippets and examples, these rules are often
// counterproductive to clarity and brevity.
"no-undef": "off",
"no-unused-expressions": "off",
"no-unused-vars": "off",
"padded-blocks": "off",
// Adding a "use strict" directive at the top of every
// code block is tedious and distracting. The config
// opts into strict mode parsing without the directive.
strict: "off",
// The processor will not receive a Unicode Byte Order
// Mark from the Markdown parser.
"unicode-bom": "off",
};
let recommendedPlugins, processorPlugins;
const plugin = {
meta: {
name: "@eslint/markdown",
version: "8.0.3", // x-release-please-version
},
processors: {
markdown: processor,
},
languages: {
commonmark: new MarkdownLanguage({ mode: "commonmark" }),
gfm: new MarkdownLanguage({ mode: "gfm" }),
},
rules,
configs: {
"recommended-legacy": {
plugins: ["markdown"],
overrides: [
{
files: ["*.md"],
processor: "markdown/markdown",
},
{
files: ["**/*.md/**"],
parserOptions: {
ecmaFeatures: {
// Adding a "use strict" directive at the top of
// every code block is tedious and distracting, so
// opt into strict mode parsing without the
// directive.
impliedStrict: true,
},
},
rules: {
...processorRulesConfig,
},
},
],
},
recommended: [
{
name: "markdown/recommended",
files: ["**/*.md"],
language: "markdown/commonmark",
plugins: (recommendedPlugins = {}),
rules: recommendedRules,
},
],
processor: /** @type {ConfigObject[]} */ ([
{
name: "markdown/recommended/plugin",
plugins: (processorPlugins = {}),
},
{
name: "markdown/recommended/processor",
files: ["**/*.md"],
processor: "markdown/markdown",
},
{
name: "markdown/recommended/code-blocks",
files: ["**/*.md/**"],
languageOptions: {
parserOptions: {
ecmaFeatures: {
// Adding a "use strict" directive at the top of
// every code block is tedious and distracting, so
// opt into strict mode parsing without the
// directive.
impliedStrict: true,
},
},
},
rules: {
...processorRulesConfig,
},
},
]),
},
};
Object.assign(recommendedPlugins, { markdown: plugin });
Object.assign(processorPlugins, { markdown: plugin });
export default plugin;
export { MarkdownSourceCode };
export * from "./language/markdown-language.js";
export * from "./types.js";
@@ -0,0 +1,73 @@
/**
* Markdown Language Object
* @implements {Language}
*/
export class MarkdownLanguage implements Language {
/**
* Creates a new instance.
* @param {Object} options The options to use for this instance.
* @param {ParserMode} [options.mode] The Markdown parser mode to use.
*/
constructor({ mode }?: {
mode?: ParserMode;
});
/**
* The type of file to read.
* @type {"text"}
*/
fileType: "text";
/**
* The line number at which the parser starts counting.
* @type {0|1}
*/
lineStart: 0 | 1;
/**
* The column number at which the parser starts counting.
* @type {0|1}
*/
columnStart: 0 | 1;
/**
* The name of the key that holds the type of the node.
* @type {string}
*/
nodeTypeKey: string;
/**
* Default language options. User-defined options are merged with this object.
* @type {MarkdownLanguageOptions}
*/
defaultLanguageOptions: MarkdownLanguageOptions;
/**
* Validates the language options.
* @param {MarkdownLanguageOptions} languageOptions The language options to validate.
* @returns {void}
* @throws {Error} When the language options are invalid.
*/
validateLanguageOptions(languageOptions: MarkdownLanguageOptions): void;
/**
* Parses the given file into an AST.
* @param {File} file The virtual file to parse.
* @param {MarkdownLanguageContext} context The options to use for parsing.
* @returns {ParseResult<Root>} The result of parsing.
*/
parse(file: File, context: MarkdownLanguageContext): ParseResult<Root>;
/**
* Creates a new `MarkdownSourceCode` object from the given information.
* @param {File} file The virtual file to create a `MarkdownSourceCode` object from.
* @param {OkParseResult<Root>} parseResult The result returned from `parse()`.
* @returns {MarkdownSourceCode} The new `MarkdownSourceCode` object.
*/
createSourceCode(file: File, parseResult: OkParseResult<Root>): MarkdownSourceCode;
#private;
}
export type Extensions = Options["extensions"];
export type MdastExtensions = Options["mdastExtensions"];
export type ParserMode = "commonmark" | "gfm";
import type { Language } from "@eslint/core";
import type { MarkdownLanguageOptions } from "../types.js";
import type { File } from "@eslint/core";
import type { MarkdownLanguageContext } from "../types.js";
import type { Root } from "mdast";
import type { ParseResult } from "@eslint/core";
import type { OkParseResult } from "@eslint/core";
import { MarkdownSourceCode } from "./markdown-source-code.js";
import type { Options } from "mdast-util-from-markdown";
@@ -0,0 +1,211 @@
/**
* @fileoverview The MarkdownLanguage class.
* @author Nicholas C. Zakas
*/
/* eslint class-methods-use-this: 0 -- Required to complete interface. */
//------------------------------------------------------------------------------
// Imports
//------------------------------------------------------------------------------
import { MarkdownSourceCode } from "./markdown-source-code.js";
import { fromMarkdown } from "mdast-util-from-markdown";
import { frontmatterFromMarkdown } from "mdast-util-frontmatter";
import { gfmFromMarkdown } from "mdast-util-gfm";
import { mathFromMarkdown } from "mdast-util-math";
import { frontmatter } from "micromark-extension-frontmatter";
import { gfm } from "micromark-extension-gfm";
import { math } from "micromark-extension-math";
//-----------------------------------------------------------------------------
// Types
//-----------------------------------------------------------------------------
/**
* @import { Language, File, ParseResult, OkParseResult } from "@eslint/core";
* @import { Root } from "mdast";
* @import { Options } from "mdast-util-from-markdown";
* @import { MarkdownLanguageOptions, MarkdownLanguageContext } from "../types.js";
* @typedef {Options['extensions']} Extensions
* @typedef {Options['mdastExtensions']} MdastExtensions
* @typedef {"commonmark"|"gfm"} ParserMode
*/
//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------
/**
* Parser configuration for JSON frontmatter.
* Example of supported frontmatter format:
* ```markdown
* ---
* {
* "title": "My Document",
* "date": "2025-06-09"
* }
* ---
* ```
*/
const jsonFrontmatterConfig = {
type: "json",
marker: "-",
};
/**
* Create parser options based on `mode` and `languageOptions`.
* @param {ParserMode} mode The markdown parser mode.
* @param {MarkdownLanguageOptions} languageOptions Language options.
* @returns {{extensions: Extensions, mdastExtensions: MdastExtensions}} Parser options for micromark and mdast.
*/
function createParserOptions(mode, languageOptions) {
/** @type {Extensions} */
const extensions = [];
/** @type {MdastExtensions} */
const mdastExtensions = [];
// 1. `mode`: Add GFM extensions if mode is "gfm"
if (mode === "gfm") {
extensions.push(gfm());
mdastExtensions.push(gfmFromMarkdown());
}
// 2. `languageOptions.frontmatter`: Handle frontmatter options
const frontmatterOption = languageOptions?.frontmatter;
// Skip frontmatter entirely if false
if (frontmatterOption !== false) {
if (frontmatterOption === "yaml") {
extensions.push(frontmatter(["yaml"]));
mdastExtensions.push(frontmatterFromMarkdown(["yaml"]));
}
else if (frontmatterOption === "toml") {
extensions.push(frontmatter(["toml"]));
mdastExtensions.push(frontmatterFromMarkdown(["toml"]));
}
else if (frontmatterOption === "json") {
extensions.push(frontmatter(jsonFrontmatterConfig));
mdastExtensions.push(frontmatterFromMarkdown(jsonFrontmatterConfig));
}
}
// 3. `languageOptions.math`: Handle math option
const mathOption = languageOptions?.math;
// Skip math entirely if false
if (mathOption === true) {
extensions.push(math());
mdastExtensions.push(mathFromMarkdown());
}
return {
extensions,
mdastExtensions,
};
}
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
/**
* Markdown Language Object
* @implements {Language}
*/
export class MarkdownLanguage {
/**
* The type of file to read.
* @type {"text"}
*/
fileType = "text";
/**
* The line number at which the parser starts counting.
* @type {0|1}
*/
lineStart = 1;
/**
* The column number at which the parser starts counting.
* @type {0|1}
*/
columnStart = 1;
/**
* The name of the key that holds the type of the node.
* @type {string}
*/
nodeTypeKey = "type";
/**
* Default language options. User-defined options are merged with this object.
* @type {MarkdownLanguageOptions}
*/
defaultLanguageOptions = {
frontmatter: false,
math: false,
};
/**
* The Markdown parser mode.
* @type {ParserMode}
*/
#mode = "commonmark";
/**
* Creates a new instance.
* @param {Object} options The options to use for this instance.
* @param {ParserMode} [options.mode] The Markdown parser mode to use.
*/
constructor({ mode } = {}) {
if (mode) {
this.#mode = mode;
}
}
/**
* Validates the language options.
* @param {MarkdownLanguageOptions} languageOptions The language options to validate.
* @returns {void}
* @throws {Error} When the language options are invalid.
*/
validateLanguageOptions(languageOptions) {
// `frontmatter` option validation
const frontmatterOption = languageOptions?.frontmatter;
const validFrontmatterOptions = new Set([
false,
"yaml",
"toml",
"json",
]);
if (frontmatterOption !== undefined &&
!validFrontmatterOptions.has(frontmatterOption)) {
throw new Error(`Invalid language option value \`${frontmatterOption}\` for frontmatter. Expected one of \`false\`, \`"yaml"\`, \`"toml"\`, or \`"json"\`.`);
}
// `math` option validation
const mathOption = languageOptions?.math;
if (mathOption !== undefined && typeof mathOption !== "boolean") {
throw new Error(`Invalid language option value \`${mathOption}\` for math. Expected a boolean.`);
}
}
/**
* Parses the given file into an AST.
* @param {File} file The virtual file to parse.
* @param {MarkdownLanguageContext} context The options to use for parsing.
* @returns {ParseResult<Root>} The result of parsing.
*/
parse(file, context) {
// Note: BOM already removed
const text = /** @type {string} */ (file.body);
/*
* Check for parsing errors first. If there's a parsing error, nothing
* else can happen. However, a parsing error does not throw an error
* from this method - it's just considered a fatal error message, a
* problem that ESLint identified just like any other.
*/
try {
const options = createParserOptions(this.#mode, context?.languageOptions);
const root = fromMarkdown(text, options);
return {
ok: true,
ast: root,
};
}
catch (ex) {
return {
ok: false,
errors: [ex],
};
}
}
/**
* Creates a new `MarkdownSourceCode` object from the given information.
* @param {File} file The virtual file to create a `MarkdownSourceCode` object from.
* @param {OkParseResult<Root>} parseResult The result returned from `parse()`.
* @returns {MarkdownSourceCode} The new `MarkdownSourceCode` object.
*/
createSourceCode(file, parseResult) {
return new MarkdownSourceCode({
text: /** @type {string} */ (file.body),
ast: parseResult.ast,
});
}
}
@@ -0,0 +1,96 @@
/**
* Represents an inline config comment in the source code.
*/
export class InlineConfigComment {
/**
* Creates a new instance.
* @param {Object} options The options for the instance.
* @param {string} options.value The comment text.
* @param {Position} options.position The position of the comment in the source code.
*/
constructor({ value, position }: {
value: string;
position: Position;
});
/**
* The comment text.
* @type {string}
*/
value: string;
/**
* The position of the comment in the source code.
* @type {Position}
*/
position: Position;
}
/**
* Markdown Source Code Object
* @extends {TextSourceCodeBase<{LangOptions: MarkdownLanguageOptions, RootNode: Root, SyntaxElementWithLoc: Node, ConfigNode: { value: string; position: Position }}>}
*/
export class MarkdownSourceCode extends TextSourceCodeBase<{
LangOptions: MarkdownLanguageOptions;
RootNode: Root;
SyntaxElementWithLoc: Node;
ConfigNode: {
value: string;
position: Position;
};
}> {
/**
* Creates a new instance.
* @param {Object} options The options for the instance.
* @param {string} options.text The source code text.
* @param {Root} options.ast The root AST node.
*/
constructor({ text, ast }: {
text: string;
ast: Root;
});
/**
* Returns the parent of the given node.
* @param {Node} node The node to get the parent of.
* @returns {Parent|undefined} The parent of the node.
*/
getParent(node: Node): Parent | undefined;
/**
* Returns an array of all inline configuration nodes found in the
* source code.
* @returns {Array<InlineConfigComment>} An array of all inline configuration nodes.
*/
getInlineConfigNodes(): Array<InlineConfigComment>;
/**
* Returns an all directive nodes that enable or disable rules along with any problems
* encountered while parsing the directives.
* @returns {{problems:Array<FileProblem>,directives:Array<Directive>}} Information
* that ESLint needs to further process the directives.
*/
getDisableDirectives(): {
problems: Array<FileProblem>;
directives: Array<Directive>;
};
/**
* Returns inline rule configurations along with any problems
* encountered while parsing the configurations.
* @returns {{problems:Array<FileProblem>,configs:Array<{config:{rules:RulesConfig},loc:Position}>}} Information
* that ESLint needs to further process the rule configurations.
*/
applyInlineConfig(): {
problems: Array<FileProblem>;
configs: Array<{
config: {
rules: RulesConfig;
};
loc: Position;
}>;
};
#private;
}
import type { Position } from "unist";
import type { MarkdownLanguageOptions } from "../types.js";
import type { Root } from "mdast";
import type { Node } from "mdast";
import { TextSourceCodeBase } from "@eslint/plugin-kit";
import type { Parent } from "mdast";
import type { FileProblem } from "@eslint/core";
import { Directive } from "@eslint/plugin-kit";
import type { RulesConfig } from "@eslint/core";
@@ -0,0 +1,279 @@
/**
* @fileoverview The MarkdownSourceCode class.
* @author Nicholas C. Zakas
*/
//-----------------------------------------------------------------------------
// Imports
//-----------------------------------------------------------------------------
import { VisitNodeStep, TextSourceCodeBase, ConfigCommentParser, Directive, } from "@eslint/plugin-kit";
import { lineEndingPattern } from "../util.js";
//-----------------------------------------------------------------------------
// Types
//-----------------------------------------------------------------------------
/**
* @import { Position } from "unist";
* @import { Parent, Root, Node, Html } from "mdast";
* @import { TraversalStep, FileProblem, DirectiveType, RulesConfig } from "@eslint/core";
* @import { MarkdownLanguageOptions } from "../types.js";
*/
//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------
const commentParser = new ConfigCommentParser();
const configCommentStart = /<!--\s*eslint(?:-enable|-disable(?:(?:-next)?-line)?)?(?:\s|-->)/u;
const htmlComment = /<!--(.*?)-->/gsu;
/**
* Represents an inline config comment in the source code.
*/
export class InlineConfigComment {
/**
* The comment text.
* @type {string}
*/
value;
/**
* The position of the comment in the source code.
* @type {Position}
*/
position;
/**
* Creates a new instance.
* @param {Object} options The options for the instance.
* @param {string} options.value The comment text.
* @param {Position} options.position The position of the comment in the source code.
*/
constructor({ value, position }) {
this.value = value.trim();
this.position = position;
}
}
/**
* Extracts inline configuration comments from an HTML node.
* @param {Html} node The HTML node to extract comments from.
* @param {MarkdownSourceCode} sourceCode The Markdown source code object.
* @returns {Array<InlineConfigComment>} The inline configuration comments found in the node.
*/
function extractInlineConfigCommentsFromHTML(node, sourceCode) {
if (!configCommentStart.test(node.value)) {
return [];
}
/** @type {Array<InlineConfigComment>} */
const comments = [];
/** @type {RegExpExecArray | null} */
let match;
while ((match = htmlComment.exec(node.value))) {
if (configCommentStart.test(match[0])) {
// calculate offset of the comment inside the node
const startOffset = match.index + node.position.start.offset;
const endOffset = startOffset + match[0].length;
comments.push(new InlineConfigComment({
value: match[1].trim(),
position: {
start: {
...sourceCode.getLocFromIndex(startOffset),
offset: startOffset,
},
end: {
...sourceCode.getLocFromIndex(endOffset),
offset: endOffset,
},
},
}));
}
}
return comments;
}
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
/**
* Markdown Source Code Object
* @extends {TextSourceCodeBase<{LangOptions: MarkdownLanguageOptions, RootNode: Root, SyntaxElementWithLoc: Node, ConfigNode: { value: string; position: Position }}>}
*/
export class MarkdownSourceCode extends TextSourceCodeBase {
/**
* Cached traversal steps.
* @type {Array<VisitNodeStep>|undefined}
*/
#steps;
/**
* Cache of parent nodes.
* @type {WeakMap<Node, Parent|undefined>}
*/
#parents = new WeakMap();
/**
* Collection of HTML nodes. Used to find directive comments.
* @type {Array<Html>}
*/
#htmlNodes = [];
/**
* Collection of inline configuration comments.
* @type {Array<InlineConfigComment>}
*/
#inlineConfigComments;
/**
* The AST of the source code.
* @type {Root}
*/
ast = undefined;
/**
* Creates a new instance.
* @param {Object} options The options for the instance.
* @param {string} options.text The source code text.
* @param {Root} options.ast The root AST node.
*/
constructor({ text, ast }) {
super({ ast, text, lineEndingPattern });
this.ast = ast;
// need to traverse the source code to get the inline config nodes
this.traverse();
}
/**
* Returns the parent of the given node.
* @param {Node} node The node to get the parent of.
* @returns {Parent|undefined} The parent of the node.
*/
getParent(node) {
return this.#parents.get(node);
}
/**
* Returns an array of all inline configuration nodes found in the
* source code.
* @returns {Array<InlineConfigComment>} An array of all inline configuration nodes.
*/
getInlineConfigNodes() {
if (!this.#inlineConfigComments) {
this.#inlineConfigComments = this.#htmlNodes.flatMap(htmlNode => extractInlineConfigCommentsFromHTML(htmlNode, this));
}
return this.#inlineConfigComments;
}
/**
* Returns an all directive nodes that enable or disable rules along with any problems
* encountered while parsing the directives.
* @returns {{problems:Array<FileProblem>,directives:Array<Directive>}} Information
* that ESLint needs to further process the directives.
*/
getDisableDirectives() {
/** @type {Array<FileProblem>} */
const problems = [];
/** @type {Array<Directive>} */
const directives = [];
this.getInlineConfigNodes().forEach(comment => {
// Step 1: Parse the directive
const { label, value, justification: justificationPart, } = commentParser.parseDirective(comment.value);
// Step 2: Validate the directive does not span multiple lines
if (label === "eslint-disable-line" &&
comment.position.start.line !== comment.position.end.line) {
const message = `${label} comment should not span multiple lines.`;
problems.push({
ruleId: null,
message,
loc: comment.position,
});
return;
}
// Step 3: Extract the directive value and create the Directive object
switch (label) {
case "eslint-disable":
case "eslint-enable":
case "eslint-disable-next-line":
case "eslint-disable-line": {
const directiveType = label.slice("eslint-".length);
directives.push(new Directive({
type: /** @type {DirectiveType} */ (directiveType),
node: comment,
value,
justification: justificationPart,
}));
}
// no default
}
});
return { problems, directives };
}
/**
* Returns inline rule configurations along with any problems
* encountered while parsing the configurations.
* @returns {{problems:Array<FileProblem>,configs:Array<{config:{rules:RulesConfig},loc:Position}>}} Information
* that ESLint needs to further process the rule configurations.
*/
applyInlineConfig() {
/** @type {Array<FileProblem>} */
const problems = [];
/** @type {Array<{config:{rules:RulesConfig},loc:Position}>} */
const configs = [];
this.getInlineConfigNodes().forEach(comment => {
const { label, value } = commentParser.parseDirective(comment.value);
if (label === "eslint") {
const parseResult = commentParser.parseJSONLikeConfig(value);
if (parseResult.ok) {
configs.push({
config: {
rules: parseResult.config,
},
loc: comment.position,
});
}
else {
problems.push({
ruleId: null,
message:
/** @type {{ok: false, error: { message: string }}} */ (parseResult).error.message,
loc: comment.position,
});
}
}
});
return {
configs,
problems,
};
}
/**
* Traverse the source code and return the steps that were taken.
* @returns {Iterable<TraversalStep>} The steps that were taken while traversing the source code.
*/
traverse() {
// Because the AST doesn't mutate, we can cache the steps
if (this.#steps) {
return this.#steps.values();
}
/** @type {Array<VisitNodeStep>} */
const steps = (this.#steps = []);
/**
* Recursively visits a node and its children.
* @param {Node} node The node to visit.
* @param {Parent} [parent] The parent of the node.
* @returns {void}
*/
const visit = (node, parent) => {
// first set the parent
this.#parents.set(node, parent);
// then add the step
steps.push(new VisitNodeStep({
target: node,
phase: 1,
args: [node, parent],
}));
// save HTML nodes
if (node.type === "html") {
this.#htmlNodes.push(/** @type {Html} */ (node));
}
// then visit the children
if ("children" in node) {
const parentNode = /** @type {Parent} */ (node);
parentNode.children.forEach(child => {
visit(child, parentNode);
});
}
// then add the exit step
steps.push(new VisitNodeStep({
target: node,
phase: 2,
args: [node, parent],
}));
};
visit(this.ast);
return steps.values();
}
}
+30
View File
@@ -0,0 +1,30 @@
export namespace processor {
export namespace meta {
let name: string;
let version: string;
}
export { preprocess };
export { postprocess };
export { SUPPORTS_AUTOFIX as supportsAutofix };
}
/**
* 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.
*/
declare function preprocess(sourceText: string, filename: string): Array<{
filename: string;
text: string;
}>;
/**
* Transforms generated messages for output.
* @param {Array<LintMessage[]>} messages An array containing one array of messages
* for each code block returned from `preprocess`.
* @param {string} filename The filename of the file
* @returns {LintMessage[]} A flattened array of messages with mapped locations.
*/
declare function postprocess(messages: Array<LintMessage[]>, filename: string): LintMessage[];
declare const SUPPORTS_AUTOFIX: true;
import type { LintMessage } from "@eslint/core";
export {};
+394
View File
@@ -0,0 +1,394 @@
/**
* @fileoverview Processes Markdown files for consumption by ESLint.
* @author Brandon Mills
*/
//-----------------------------------------------------------------------------
// Imports
//-----------------------------------------------------------------------------
import { fromMarkdown } from "mdast-util-from-markdown";
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
* @import { LintMessage, RuleTextEdit, SourceRange } from "@eslint/core";
* @import { Node, Parent, Code, Html } from "mdast";
* @import { Block, RangeMap } from "./types.js";
*/
//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------
const UNSATISFIABLE_RULES = new Set([
"eol-last", // The Markdown parser strips trailing newlines in code fences
"unicode-bom", // Code blocks will begin in the middle of Markdown files
]);
const SUPPORTS_AUTOFIX = true;
const BOM = "\uFEFF";
/**
* @type {Map<string, Block[]>}
*/
const blocksCache = new Map();
/**
* Performs a depth-first traversal of the Markdown AST.
* @param {Node} node A Markdown AST node.
* @param {{[key: string]: (() => void) | ((node: Code) => void) | ((node: Html) => void)}} callbacks A map of node types to callbacks.
* @returns {void}
*/
function traverse(node, callbacks) {
if (callbacks[node.type]) {
/** @type {(node: Node) => void} */ (callbacks[node.type])(node);
}
else {
/** @type {() => void} */ (callbacks["*"])();
}
const parent = /** @type {Parent} */ (node);
if (typeof parent.children !== "undefined") {
for (let i = 0; i < parent.children.length; i++) {
traverse(parent.children[i], callbacks);
}
}
}
/**
* Extracts `eslint-*` or `global` comments from HTML comments if present.
* @param {string} html The text content of an HTML AST node.
* @returns {string} The comment's text without the opening and closing tags or
* an empty string if the text is not an ESLint HTML comment.
*/
function getComment(html) {
const commentStart = "<!--";
const commentEnd = "-->";
const regex = /^(?:eslint\b|global\s)/u;
if (html.slice(0, commentStart.length) !== commentStart ||
html.slice(-commentEnd.length) !== commentEnd) {
return "";
}
const comment = html.slice(commentStart.length, -commentEnd.length);
if (!regex.test(comment.trim())) {
return "";
}
return comment;
}
// Before a code block, blockquote characters (`>`) are also considered
// "whitespace".
const leadingWhitespaceRegex = /^[>\s]*/u;
/**
* Gets the offset for the first column of the node's first line in the
* original source text.
* @param {Code} node A Markdown code block AST node.
* @returns {number} The offset for the first column of the node's first line.
*/
function getBeginningOfLineOffset(node) {
return node.position.start.offset - node.position.start.column + 1;
}
/**
* Gets the leading text, typically whitespace with possible blockquote chars,
* used to indent a code block.
* @param {string} text The text of the file.
* @param {Code} node A Markdown code block AST node.
* @returns {string} The text from the start of the first line to the opening
* fence of the code block.
*/
function getIndentText(text, node) {
return leadingWhitespaceRegex.exec(text.slice(getBeginningOfLineOffset(node)))[0];
}
/**
* When applying fixes, the postprocess step needs to know how to map fix ranges
* from their location in the linted JS to the original offset in the Markdown.
* Configuration comments and indentation trimming both complicate this process.
*
* Configuration comments appear in the linted JS but not in the Markdown code
* block. Fixes to configuration comments would cause undefined behavior and
* should be ignored during postprocessing. Fixes to actual code after
* configuration comments need to be mapped back to the code block after
* removing any offset due to configuration comments.
*
* Fenced code blocks can be indented by up to three spaces at the opening
* fence. Inside of a list, for example, this indent can be in addition to the
* indent already required for list item children. Leading whitespace inside
* indented code blocks is trimmed up to the level of the opening fence and does
* not appear in the linted code. Further, lines can have less leading
* whitespace than the opening fence, so not all lines are guaranteed to have
* the same column offset as the opening fence.
*
* The source code of a non-configuration-comment line in the linted JS is a
* suffix of the corresponding line in the Markdown code block. There are no
* differences within the line, so the mapping need only provide the offset
* delta at the beginning of each line.
* @param {string} text The text of the file.
* @param {Code} node A Markdown code block AST node.
* @param {string[]} comments List of configuration comment strings that will be
* inserted at the beginning of the code block.
* @returns {RangeMap[]} A list of offset-based adjustments, where lookups are
* done based on the `js` key, which represents the range in the linted JS,
* and the `md` key is the offset delta that, when added to the JS range,
* returns the corresponding location in the original Markdown source.
*/
function getBlockRangeMap(text, node, comments) {
/*
* The parser sets the fenced code block's start offset to wherever content
* should normally begin (typically the first column of the line, but more
* inside a list item, for example). The code block's opening fence may be
* further indented by up to three characters. If the code block has
* additional indenting, the opening fence's first backtick may be up to
* three whitespace characters after the start offset.
*/
const startOffset = getBeginningOfLineOffset(node);
/*
* Extract the Markdown source to determine the leading whitespace for each
* line.
*/
const code = text.slice(startOffset, node.position.end.offset);
const lines = code.split("\n");
/*
* The parser trims leading whitespace from each line of code within the
* fenced code block up to the opening fence's first backtick. The first
* backtick's column is the AST node's starting column plus any additional
* indentation.
*/
const baseIndent = getIndentText(text, node).length;
/*
* Track the length of any inserted configuration comments at the beginning
* of the linted JS and start the JS offset lookup keys at this index.
*/
const commentLength = comments.reduce((len, comment) => len + comment.length + 1, 0);
/*
* In case there are configuration comments, initialize the map so that the
* first lookup index is always 0. If there are no configuration comments,
* the lookup index will also be 0, and the lookup should always go to the
* last range that matches, skipping this initialization entry.
*/
const rangeMap = [
{
indent: baseIndent,
js: 0,
md: 0,
},
];
// Start the JS offset after any configuration comments.
let jsOffset = commentLength;
/*
* Start the Markdown offset at the beginning of the block's first line of
* actual code. The first line of the block is always the opening fence, so
* the code begins on the second line.
*/
let mdOffset = startOffset + lines[0].length + 1;
/*
* For each line, determine how much leading whitespace was trimmed due to
* indentation. Increase the JS lookup offset by the length of the line
* post-trimming and the Markdown offset by the total line length.
*/
for (let i = 0; i + 1 < lines.length; i++) {
const line = lines[i + 1];
const leadingWhitespaceLength = leadingWhitespaceRegex.exec(line)[0].length;
// The parser trims leading whitespace up to the level of the opening
// fence, so keep any additional indentation beyond that.
const trimLength = Math.min(baseIndent, leadingWhitespaceLength);
rangeMap.push({
indent: trimLength,
js: jsOffset,
// Advance `trimLength` character from the beginning of the Markdown
// line to the beginning of the equivalent JS line, then compute the
// delta.
md: mdOffset + trimLength - jsOffset,
});
// Accumulate the current line in the offsets, and don't forget the
// newline.
mdOffset += line.length + 1;
jsOffset += line.length - trimLength + 1;
}
return rangeMap;
}
const codeBlockFileNameRegex = /filename=(?<quote>["'])(?<filename>.*?)\1/u;
/**
* Parses the file name from a block meta, if available.
* @param {Block} block A code block.
* @returns {string | null | undefined} The filename, if parsed from block meta.
*/
function fileNameFromMeta(block) {
return block.meta
?.match(codeBlockFileNameRegex)
?.groups.filename.replaceAll(/\s+/gu, "_");
}
const languageToFileExtension = {
javascript: "js",
ecmascript: "js",
typescript: "ts",
markdown: "md",
};
/**
* 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.
*/
function preprocess(sourceText, filename) {
const text = sourceText.startsWith(BOM) ? sourceText.slice(1) : sourceText;
const ast = fromMarkdown(text);
/** @type {Block[]} */
const blocks = [];
blocksCache.set(filename, blocks);
/**
* During the depth-first traversal, keep track of any sequences of HTML
* comment nodes containing `eslint-*` or `global` comments. If a code
* block immediately follows such a sequence, insert the comments at the
* top of the code block. Any non-ESLint comment or other node type breaks
* and empties the sequence.
* @type {string[]}
*/
let htmlComments = [];
traverse(ast, {
"*"() {
htmlComments = [];
},
/**
* Visit a code node.
* @param {Code} node The visited node.
* @returns {void}
*/
code(node) {
if (node.lang) {
/** @type {string[]} */
const comments = [];
for (const comment of htmlComments) {
if (comment.trim() === "eslint-skip") {
htmlComments = [];
return;
}
comments.push(`/*${comment}*/`);
}
htmlComments = [];
blocks.push({
...node,
baseIndentText: getIndentText(text, node),
comments,
rangeMap: getBlockRangeMap(text, node, comments),
});
}
},
/**
* Visit an HTML node.
* @param {Html} node The visited node.
* @returns {void}
*/
html(node) {
const comment = getComment(node.value);
if (comment) {
htmlComments.push(comment);
}
else {
htmlComments = [];
}
},
});
return blocks.map((block, index) => {
const [language] = block.lang.trim().split(" ");
const fileExtension = Object.hasOwn(languageToFileExtension, language)
? languageToFileExtension[
/** @type {keyof typeof languageToFileExtension} */ (language)]
: language;
return {
filename: fileNameFromMeta(block) ?? `${index}.${fileExtension}`,
text: [...block.comments, block.value, ""].join("\n"),
};
});
}
/**
* Adjusts a fix in a code block.
* @param {Block} block A code block.
* @param {RuleTextEdit} fix A fix to adjust.
* @returns {RuleTextEdit} The fix with adjusted ranges.
*/
function adjustFix(block, fix) {
return {
range: /** @type {SourceRange} */ (fix.range.map(range => {
// Advance through the block's range map to find the last
// matching range by finding the first range too far and
// then going back one.
let i = 1;
while (i < block.rangeMap.length &&
block.rangeMap[i].js <= range) {
i++;
}
// Apply the mapping delta for this range.
return range + block.rangeMap[i - 1].md;
})),
text: fix.text.replace(/\n/gu, `\n${block.baseIndentText}`),
};
}
/**
* Creates a map function that adjusts messages in a code block.
* @param {Block} block A code block.
* @returns {(message: LintMessage) => LintMessage | null} A function that adjusts messages in a code block.
*/
function adjustBlock(block) {
const leadingCommentLines = block.comments.reduce((count, comment) => count + comment.split("\n").length, 0);
const blockStart = block.position.start.line;
/**
* Adjusts ESLint messages to point to the correct location in the Markdown.
* @param {LintMessage} message A message from ESLint.
* @returns {LintMessage} The same message, but adjusted to the correct location.
*/
return function adjustMessage(message) {
if (!Number.isInteger(message.line)) {
return {
...message,
line: blockStart,
column: block.position.start.column,
};
}
const lineInCode = message.line - leadingCommentLines;
if (lineInCode < 1 || lineInCode >= block.rangeMap.length) {
return null;
}
/** @type {Pick<LintMessage, "line" | "column" | "endLine" | "suggestions">} */
const out = {
line: lineInCode + blockStart,
column: message.column + block.rangeMap[lineInCode].indent,
};
if (Number.isInteger(message.endLine)) {
out.endLine = message.endLine - leadingCommentLines + blockStart;
}
if (Array.isArray(message.suggestions)) {
out.suggestions = message.suggestions.map(suggestion => ({
...suggestion,
fix: adjustFix(block, suggestion.fix),
}));
}
const adjustedFix = {};
if (message.fix) {
adjustedFix.fix = adjustFix(block, message.fix);
}
return { ...message, ...out, ...adjustedFix };
};
}
/**
* Excludes unsatisfiable rules from the list of messages.
* @param {LintMessage} message A message from the linter.
* @returns {boolean} True if the message should be included in output.
*/
function excludeUnsatisfiableRules(message) {
return message && !UNSATISFIABLE_RULES.has(message.ruleId);
}
/**
* Transforms generated messages for output.
* @param {Array<LintMessage[]>} messages An array containing one array of messages
* for each code block returned from `preprocess`.
* @param {string} filename The filename of the file
* @returns {LintMessage[]} A flattened array of messages with mapped locations.
*/
function postprocess(messages, filename) {
const blocks = blocksCache.get(filename);
blocksCache.delete(filename);
return messages.flatMap((group, i) => {
const adjust = adjustBlock(blocks[i]);
return group.map(adjust).filter(excludeUnsatisfiableRules);
});
}
export const processor = {
meta: {
name: "@eslint/markdown/markdown",
version: "8.0.3", // x-release-please-version
},
preprocess,
postprocess,
supportsAutofix: SUPPORTS_AUTOFIX,
};
@@ -0,0 +1,49 @@
declare namespace _default {
namespace meta {
let type: "problem";
namespace docs {
let recommended: boolean;
let description: string;
let url: string;
}
namespace messages {
let missingLanguage: string;
let disallowedLanguage: string;
}
let schema: {
type: "object";
properties: {
required: {
type: "array";
items: {
type: "string";
};
uniqueItems: true;
};
};
additionalProperties: false;
}[];
let defaultOptions: [{
required: any[];
}];
}
function create(context: import("@eslint/core").RuleContext<{
LangOptions: import("../types.js").MarkdownLanguageOptions;
Code: import("../index.js").MarkdownSourceCode;
RuleOptions: FencedCodeLanguageOptions;
Node: import("mdast").Node | import("../language/markdown-source-code.js").InlineConfigComment;
MessageIds: FencedCodeLanguageMessageIds;
}>): {
code(node: import("mdast").Code): void;
};
}
export default _default;
export type FencedCodeLanguageMessageIds = "missingLanguage" | "disallowedLanguage";
export type FencedCodeLanguageOptions = [{
required?: string[];
}];
export type FencedCodeLanguageRuleDefinition = MarkdownRuleDefinition<{
RuleOptions: FencedCodeLanguageOptions;
MessageIds: FencedCodeLanguageMessageIds;
}>;
import type { MarkdownRuleDefinition } from "../types.js";
@@ -0,0 +1,106 @@
/**
* @fileoverview Rule to enforce languages for fenced code.
* @author Nicholas C. Zakas
*/
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
* @import { MarkdownRuleDefinition } from "../types.js";
* @typedef {"missingLanguage" | "disallowedLanguage"} FencedCodeLanguageMessageIds
* @typedef {[{ required?: string[] }]} FencedCodeLanguageOptions
* @typedef {MarkdownRuleDefinition<{ RuleOptions: FencedCodeLanguageOptions, MessageIds: FencedCodeLanguageMessageIds }>} FencedCodeLanguageRuleDefinition
*/
//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------
const fencedCodeCharacters = new Set(["`", "~"]);
//-----------------------------------------------------------------------------
// Rule Definition
//-----------------------------------------------------------------------------
export default /** @satisfies {FencedCodeLanguageRuleDefinition} */ ({
meta: {
type: "problem",
docs: {
recommended: true,
description: "Require languages for fenced code blocks",
url: "https://github.com/eslint/markdown/blob/main/docs/rules/fenced-code-language.md",
},
messages: {
missingLanguage: "Missing code block language.",
disallowedLanguage: 'Code block language "{{lang}}" is not allowed.',
},
schema: [
{
type: "object",
properties: {
required: {
type: "array",
items: {
type: "string",
},
uniqueItems: true,
},
},
additionalProperties: false,
},
],
defaultOptions: [
{
required: [],
},
],
},
create(context) {
const required = new Set(context.options[0].required);
const { sourceCode } = context;
return {
code(node) {
if (!node.lang) {
// only check fenced code blocks
if (!fencedCodeCharacters.has(sourceCode.text[node.position.start.offset])) {
return;
}
/** @type {number} */
let openingCodeFenceEndOffset;
for (openingCodeFenceEndOffset = node.position.start.offset; fencedCodeCharacters.has(sourceCode.text[openingCodeFenceEndOffset]); openingCodeFenceEndOffset++) {
// Find the end offset of the opening code fence.
}
context.report({
loc: {
start: node.position.start,
end: {
line: node.position.start.line,
column: node.position.start.column +
openingCodeFenceEndOffset -
node.position.start.offset,
},
},
messageId: "missingLanguage",
});
return;
}
if (required.size && !required.has(node.lang)) {
const langIndex = sourceCode
.getText(node)
.indexOf(node.lang);
context.report({
loc: {
start: node.position.start,
end: {
line: node.position.start.line,
column: node.position.start.column +
langIndex +
node.lang.length,
},
},
messageId: "disallowedLanguage",
data: {
lang: node.lang,
},
});
}
},
};
},
});
@@ -0,0 +1,35 @@
declare namespace _default {
namespace meta {
let type: "problem";
namespace docs {
let recommended: boolean;
let description: string;
let url: string;
}
namespace messages {
let missingMetadata: string;
let disallowedMetadata: string;
}
let schema: {
enum: string[];
}[];
let defaultOptions: ["always"];
}
function create(context: import("@eslint/core").RuleContext<{
LangOptions: import("../types.js").MarkdownLanguageOptions;
Code: import("../index.js").MarkdownSourceCode;
RuleOptions: FencedCodeMetaOptions;
Node: import("mdast").Node | import("../language/markdown-source-code.js").InlineConfigComment;
MessageIds: FencedCodeMetaMessageIds;
}>): {
code(node: import("mdast").Code): void;
};
}
export default _default;
export type FencedCodeMetaMessageIds = "missingMetadata" | "disallowedMetadata";
export type FencedCodeMetaOptions = ["always" | "never"];
export type FencedCodeMetaRuleDefinition = MarkdownRuleDefinition<{
RuleOptions: FencedCodeMetaOptions;
MessageIds: FencedCodeMetaMessageIds;
}>;
import type { MarkdownRuleDefinition } from "../types.js";
@@ -0,0 +1,82 @@
/**
* @fileoverview Rule to require or disallow metadata for fenced code blocks.
* @author TKDev7
*/
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
* @import { MarkdownRuleDefinition } from "../types.js";
* @typedef {"missingMetadata" | "disallowedMetadata"} FencedCodeMetaMessageIds
* @typedef {["always" | "never"]} FencedCodeMetaOptions
* @typedef {MarkdownRuleDefinition<{ RuleOptions: FencedCodeMetaOptions, MessageIds: FencedCodeMetaMessageIds }>} FencedCodeMetaRuleDefinition
*/
//-----------------------------------------------------------------------------
// Rule Definition
//-----------------------------------------------------------------------------
export default /** @satisfies {FencedCodeMetaRuleDefinition} */ ({
meta: {
type: "problem",
docs: {
recommended: false,
description: "Require or disallow metadata for fenced code blocks",
url: "https://github.com/eslint/markdown/blob/main/docs/rules/fenced-code-meta.md",
},
messages: {
missingMetadata: "Missing code block metadata.",
disallowedMetadata: "Code block metadata is not allowed.",
},
schema: [
{
enum: ["always", "never"],
},
],
defaultOptions: ["always"],
},
create(context) {
const [mode] = context.options;
const { sourceCode } = context;
return {
code(node) {
const lineText = sourceCode.lines[node.position.start.line - 1];
const fenceLineText = lineText.slice(node.position.start.column - 1);
if (mode === "always") {
if (node.lang && !node.meta) {
const langIndex = fenceLineText.indexOf(node.lang);
context.report({
loc: {
start: node.position.start,
end: {
line: node.position.start.line,
column: node.position.start.column +
langIndex +
node.lang.length,
},
},
messageId: "missingMetadata",
});
}
return;
}
if (node.meta) {
const metaIndex = fenceLineText.lastIndexOf(node.meta);
context.report({
loc: {
start: {
line: node.position.start.line,
column: node.position.start.column + metaIndex,
},
end: {
line: node.position.start.line,
column: node.position.start.column +
metaIndex +
node.meta.trimEnd().length,
},
},
messageId: "disallowedMetadata",
});
}
},
};
},
});
@@ -0,0 +1,47 @@
declare namespace _default {
namespace meta {
let type: "problem";
namespace docs {
let recommended: boolean;
let description: string;
let url: string;
}
namespace messages {
let skippedHeading: string;
}
let schema: {
type: "object";
properties: {
frontmatterTitle: {
type: "string";
};
};
additionalProperties: false;
}[];
let defaultOptions: [{
frontmatterTitle: string;
}];
}
function create(context: import("@eslint/core").RuleContext<{
LangOptions: import("../types.js").MarkdownLanguageOptions;
Code: import("../index.js").MarkdownSourceCode;
RuleOptions: HeadingIncrementOptions;
Node: import("mdast").Node | import("../language/markdown-source-code.js").InlineConfigComment;
MessageIds: "skippedHeading";
}>): {
yaml(node: import("mdast").Yaml): void;
toml(node: import("../types.js").Toml): void;
json(node: import("../types.js").Json): void;
heading(node: import("mdast").Heading): void;
};
}
export default _default;
export type HeadingIncrementMessageIds = "skippedHeading";
export type HeadingIncrementOptions = [{
frontmatterTitle?: string;
}];
export type HeadingIncrementRuleDefinition = MarkdownRuleDefinition<{
RuleOptions: HeadingIncrementOptions;
MessageIds: HeadingIncrementMessageIds;
}>;
import type { MarkdownRuleDefinition } from "../types.js";
@@ -0,0 +1,84 @@
/**
* @fileoverview Rule to enforce heading levels increment by one.
* @author Nicholas C. Zakas
*/
//-----------------------------------------------------------------------------
// Imports
//-----------------------------------------------------------------------------
import { frontmatterHasTitle } from "../util.js";
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
* @import { MarkdownRuleDefinition } from "../types.js";
* @typedef {"skippedHeading"} HeadingIncrementMessageIds
* @typedef {[{ frontmatterTitle?: string }]} HeadingIncrementOptions
* @typedef {MarkdownRuleDefinition<{ RuleOptions: HeadingIncrementOptions, MessageIds: HeadingIncrementMessageIds }>} HeadingIncrementRuleDefinition
*/
//-----------------------------------------------------------------------------
// Rule Definition
//-----------------------------------------------------------------------------
export default /** @satisfies {HeadingIncrementRuleDefinition} */ ({
meta: {
type: "problem",
docs: {
recommended: true,
description: "Enforce heading levels increment by one",
url: "https://github.com/eslint/markdown/blob/main/docs/rules/heading-increment.md",
},
messages: {
skippedHeading: "Heading level skipped from {{fromLevel}} to {{toLevel}}.",
},
schema: [
{
type: "object",
properties: {
frontmatterTitle: {
type: "string",
},
},
additionalProperties: false,
},
],
defaultOptions: [
{
frontmatterTitle: "^(?!\\s*['\"]title[:=]['\"])\\s*\\{?\\s*['\"]?title['\"]?\\s*[:=]",
},
],
},
create(context) {
const [{ frontmatterTitle }] = context.options;
const titlePattern = frontmatterTitle === "" ? null : new RegExp(frontmatterTitle, "iu");
let lastHeadingDepth = 0;
return {
yaml(node) {
if (frontmatterHasTitle(node.value, titlePattern)) {
lastHeadingDepth = 1;
}
},
toml(node) {
if (frontmatterHasTitle(node.value, titlePattern)) {
lastHeadingDepth = 1;
}
},
json(node) {
if (frontmatterHasTitle(node.value, titlePattern)) {
lastHeadingDepth = 1;
}
},
heading(node) {
if (lastHeadingDepth > 0 && node.depth > lastHeadingDepth + 1) {
context.report({
loc: node.position,
messageId: "skippedHeading",
data: {
fromLevel: lastHeadingDepth,
toLevel: node.depth,
},
});
}
lastHeadingDepth = node.depth;
},
};
},
});
+37
View File
@@ -0,0 +1,37 @@
declare namespace _default {
namespace meta {
let type: "problem";
namespace docs {
let description: string;
let url: string;
}
let fixable: "code";
namespace messages {
let bareUrl: string;
}
}
function create(context: import("@eslint/core").RuleContext<{
LangOptions: import("../types.js").MarkdownLanguageOptions;
Code: import("../index.js").MarkdownSourceCode;
RuleOptions: [];
Node: import("mdast").Node | import("../language/markdown-source-code.js").InlineConfigComment;
MessageIds: "bareUrl";
}>): {
":matches(heading, paragraph, tableCell) html"(node: Html): void;
":matches(heading, paragraph, tableCell) link"(node: Link): void;
"heading:exit"(): void;
"paragraph:exit"(): void;
"tableCell:exit"(): void;
"root:exit"(): void;
};
}
export default _default;
export type NoBareUrlsMessageIds = "bareUrl";
export type NoBareUrlsOptions = [];
export type NoBareUrlsRuleDefinition = MarkdownRuleDefinition<{
RuleOptions: NoBareUrlsOptions;
MessageIds: NoBareUrlsMessageIds;
}>;
import type { Html } from "mdast";
import type { Link } from "mdast";
import type { MarkdownRuleDefinition } from "../types.js";
+147
View File
@@ -0,0 +1,147 @@
/**
* @fileoverview Rule to prevent bare URLs in Markdown.
* @author xbinaryx
*/
/*
* Here's a note on how the approach (algorithm) works:
*
* - When entering an `Html` node that is a child of a `Heading`, `Paragraph` or `TableCell`,
* we check whether it is an opening or closing tag.
* If we encounter an opening tag, we store the tag name and set `lastTagName`.
* (`lastTagName` serves as a state to represent whether we're between opening and closing HTML tags.)
* If we encounter a closing tag, we reset the stored tag name and `tempLinkNodes`.
*
* - When entering a `Link` node that is a child of a `Heading`, `Paragraph` or `TableCell`,
* we check whether it is between opening and closing HTML tags.
* If it's between opening and closing HTML tags, we add it to `tempLinkNodes`.
* If it's not between opening and closing HTML tags, we add it to `linkNodes`.
*
* - When exiting a `Heading`, `Paragraph` or `TableCell`, we add all `tempLinkNodes` to `linkNodes`.
* If there are any remaining `tempLinkNodes`, it means they are not between opening and closing HTML tags. (ex. `<br> ... <br>`)
* If there are no remaining `tempLinkNodes`, it means they are between opening and closing HTML tags.
*
* - When exiting a `root` node, we report all `Link` nodes for bare URLs.
*/
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
* @import { Link, Html } from "mdast";
* @import { MarkdownRuleDefinition } from "../types.js";
* @typedef {"bareUrl"} NoBareUrlsMessageIds
* @typedef {[]} NoBareUrlsOptions
* @typedef {MarkdownRuleDefinition<{ RuleOptions: NoBareUrlsOptions, MessageIds: NoBareUrlsMessageIds }>} NoBareUrlsRuleDefinition
*/
//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------
const htmlTagNamePattern = /^<(?<tagName>[^!>][^/\s>]*)/u;
/**
* Parses an HTML tag to extract its name and closing status
* @param {string} tagText The HTML tag text to parse
* @returns {{ name: string, isClosing: boolean } | null} Object containing tag name and closing status, or null if not a valid tag
*/
function parseHtmlTag(tagText) {
const match = tagText.match(htmlTagNamePattern);
if (match) {
const tagName = match.groups.tagName.toLowerCase();
const isClosing = tagName.startsWith("/");
return {
name: isClosing ? tagName.slice(1) : tagName,
isClosing,
};
}
return null;
}
//-----------------------------------------------------------------------------
// Rule Definition
//-----------------------------------------------------------------------------
export default /** @satisfies {NoBareUrlsRuleDefinition} */ ({
meta: {
type: "problem",
docs: {
description: "Disallow bare URLs",
url: "https://github.com/eslint/markdown/blob/main/docs/rules/no-bare-urls.md",
},
fixable: "code",
messages: {
bareUrl: "Unexpected bare URL. Use autolink (<URL>) or link ([text](URL)) instead.",
},
},
create(context) {
const { sourceCode } = context;
/**
* This array is used to store all `Link` nodes for the final report.
* @type {Array<Link>}
*/
const linkNodes = [];
/**
* This array is used to store `Link` nodes that are estimated to be between opening and closing HTML tags.
* @type {Array<Link>}
*/
const tempLinkNodes = [];
/** @type {string | null} */
let lastTagName = null;
/**
* Resets `tempLinkNodes` and `lastTagName`
* @returns {void}
*/
function reset() {
tempLinkNodes.length = 0;
lastTagName = null;
}
return {
":matches(heading, paragraph, tableCell) html"(
/** @type {Html} */ node) {
const tagInfo = parseHtmlTag(node.value);
if (!tagInfo) {
return;
}
if (!tagInfo.isClosing && lastTagName === null) {
lastTagName = tagInfo.name;
}
if (tagInfo.isClosing && lastTagName === tagInfo.name) {
reset();
}
},
":matches(heading, paragraph, tableCell) link"(
/** @type {Link} */ node) {
if (lastTagName !== null) {
tempLinkNodes.push(node);
}
else {
linkNodes.push(node);
}
},
"heading:exit"() {
linkNodes.push(...tempLinkNodes);
reset();
},
"paragraph:exit"() {
linkNodes.push(...tempLinkNodes);
reset();
},
"tableCell:exit"() {
linkNodes.push(...tempLinkNodes);
reset();
},
"root:exit"() {
for (const linkNode of linkNodes) {
const text = sourceCode.getText(linkNode);
const { url } = linkNode;
if (url === text ||
url === `http://${text}` ||
url === `mailto:${text}`) {
context.report({
node: linkNode,
messageId: "bareUrl",
fix(fixer) {
return fixer.replaceText(linkNode, `<${text}>`);
},
});
}
}
},
};
},
});
@@ -0,0 +1,66 @@
declare namespace _default {
namespace meta {
let type: "problem";
namespace docs {
let recommended: boolean;
let description: string;
let url: string;
}
namespace messages {
let duplicateDefinition: string;
let duplicateFootnoteDefinition: string;
}
let schema: {
type: "object";
properties: {
allowDefinitions: {
type: "array";
items: {
type: "string";
};
uniqueItems: true;
};
allowFootnoteDefinitions: {
type: "array";
items: {
type: "string";
};
uniqueItems: true;
};
checkFootnoteDefinitions: {
type: "boolean";
};
};
additionalProperties: false;
}[];
let defaultOptions: [{
allowDefinitions: string[];
allowFootnoteDefinitions: any[];
checkFootnoteDefinitions: true;
}];
}
function create(context: import("@eslint/core").RuleContext<{
LangOptions: import("../types.js").MarkdownLanguageOptions;
Code: import("../index.js").MarkdownSourceCode;
RuleOptions: NoDuplicateDefinitionsOptions;
Node: import("mdast").Node | import("../language/markdown-source-code.js").InlineConfigComment;
MessageIds: NoDuplicateDefinitionsMessageIds;
}>): {
definition(node: Definition): void;
footnoteDefinition(node: FootnoteDefinition): void;
};
}
export default _default;
export type NoDuplicateDefinitionsMessageIds = "duplicateDefinition" | "duplicateFootnoteDefinition";
export type NoDuplicateDefinitionsOptions = [{
allowDefinitions?: string[];
allowFootnoteDefinitions?: string[];
checkFootnoteDefinitions?: boolean;
}];
export type NoDuplicateDefinitionsRuleDefinition = MarkdownRuleDefinition<{
RuleOptions: NoDuplicateDefinitionsOptions;
MessageIds: NoDuplicateDefinitionsMessageIds;
}>;
import type { Definition } from "mdast";
import type { FootnoteDefinition } from "mdast";
import type { MarkdownRuleDefinition } from "../types.js";
@@ -0,0 +1,121 @@
/**
* @fileoverview Rule to prevent duplicate definitions in Markdown.
* @author 루밀LuMir(lumirlumir)
*/
//-----------------------------------------------------------------------------
// Imports
//-----------------------------------------------------------------------------
import { normalizeIdentifier } from "micromark-util-normalize-identifier";
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
* @import { Definition, FootnoteDefinition } from "mdast";
* @import { MarkdownRuleDefinition } from "../types.js";
* @typedef {"duplicateDefinition" | "duplicateFootnoteDefinition"} NoDuplicateDefinitionsMessageIds
* @typedef {[{ allowDefinitions?: string[], allowFootnoteDefinitions?: string[], checkFootnoteDefinitions?: boolean }]} NoDuplicateDefinitionsOptions
* @typedef {MarkdownRuleDefinition<{ RuleOptions: NoDuplicateDefinitionsOptions, MessageIds: NoDuplicateDefinitionsMessageIds }>} NoDuplicateDefinitionsRuleDefinition
*/
//-----------------------------------------------------------------------------
// Rule Definition
//-----------------------------------------------------------------------------
export default /** @satisfies {NoDuplicateDefinitionsRuleDefinition} */ ({
meta: {
type: "problem",
docs: {
recommended: true,
description: "Disallow duplicate definitions",
url: "https://github.com/eslint/markdown/blob/main/docs/rules/no-duplicate-definitions.md",
},
messages: {
duplicateDefinition: "Unexpected duplicate definition `{{ identifier }}` (label: `{{ label }}`) found. First defined at line {{ firstLine }} (label: `{{ firstLabel }}`).",
duplicateFootnoteDefinition: "Unexpected duplicate footnote definition `{{ identifier }}` (label: `{{ label }}`) found. First defined at line {{ firstLine }} (label: `{{ firstLabel }}`).",
},
schema: [
{
type: "object",
properties: {
allowDefinitions: {
type: "array",
items: {
type: "string",
},
uniqueItems: true,
},
allowFootnoteDefinitions: {
type: "array",
items: {
type: "string",
},
uniqueItems: true,
},
checkFootnoteDefinitions: {
type: "boolean",
},
},
additionalProperties: false,
},
],
defaultOptions: [
{
allowDefinitions: ["//"],
allowFootnoteDefinitions: [],
checkFootnoteDefinitions: true,
},
],
},
create(context) {
const allowDefinitions = new Set(context.options[0].allowDefinitions.map(identifier => normalizeIdentifier(identifier).toLowerCase()));
const allowFootnoteDefinitions = new Set(context.options[0].allowFootnoteDefinitions.map(identifier => normalizeIdentifier(identifier).toLowerCase()));
const [{ checkFootnoteDefinitions }] = context.options;
/** @type {Map<string, Definition>} */
const definitions = new Map();
/** @type {Map<string, FootnoteDefinition>} */
const footnoteDefinitions = new Map();
return {
definition(node) {
if (allowDefinitions.has(node.identifier)) {
return;
}
if (definitions.has(node.identifier)) {
const firstDefinitionNode = definitions.get(node.identifier);
context.report({
node,
messageId: "duplicateDefinition",
data: {
identifier: node.identifier,
label: node.label.trim(),
firstLine: firstDefinitionNode.position.start.line,
firstLabel: firstDefinitionNode.label.trim(),
},
});
}
else {
definitions.set(node.identifier, node);
}
},
footnoteDefinition(node) {
if (!checkFootnoteDefinitions ||
allowFootnoteDefinitions.has(node.identifier)) {
return;
}
if (footnoteDefinitions.has(node.identifier)) {
const firstFootnoteDefinitionNode = footnoteDefinitions.get(node.identifier);
context.report({
node,
messageId: "duplicateFootnoteDefinition",
data: {
identifier: node.identifier,
label: node.label,
firstLine: firstFootnoteDefinitionNode.position.start.line,
firstLabel: firstFootnoteDefinitionNode.label,
},
});
}
else {
footnoteDefinitions.set(node.identifier, node);
}
},
};
},
});
@@ -0,0 +1,45 @@
declare namespace _default {
namespace meta {
let type: "problem";
namespace docs {
let description: string;
let url: string;
}
namespace messages {
let duplicateHeading: string;
}
let schema: {
type: "object";
properties: {
checkSiblingsOnly: {
type: "boolean";
};
};
additionalProperties: false;
}[];
let defaultOptions: [{
checkSiblingsOnly: false;
}];
}
function create(context: import("@eslint/core").RuleContext<{
LangOptions: import("../types.js").MarkdownLanguageOptions;
Code: import("../index.js").MarkdownSourceCode;
RuleOptions: NoDuplicateHeadingsOptions;
Node: import("mdast").Node | import("../language/markdown-source-code.js").InlineConfigComment;
MessageIds: "duplicateHeading";
}>): {
heading(node: import("mdast").Heading): void;
"heading *"({ type, value }: any): void;
"heading:exit"(node: import("mdast").Heading): void;
};
}
export default _default;
export type NoDuplicateHeadingsMessageIds = "duplicateHeading";
export type NoDuplicateHeadingsOptions = [{
checkSiblingsOnly?: boolean;
}];
export type NoDuplicateHeadingsRuleDefinition = MarkdownRuleDefinition<{
RuleOptions: NoDuplicateHeadingsOptions;
MessageIds: NoDuplicateHeadingsMessageIds;
}>;
import type { MarkdownRuleDefinition } from "../types.js";
@@ -0,0 +1,101 @@
/**
* @fileoverview Rule to prevent duplicate headings in Markdown.
* @author Nicholas C. Zakas
*/
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
* @import { MarkdownRuleDefinition } from "../types.js";
* @typedef {"duplicateHeading"} NoDuplicateHeadingsMessageIds
* @typedef {[{ checkSiblingsOnly?: boolean }]} NoDuplicateHeadingsOptions
* @typedef {MarkdownRuleDefinition<{ RuleOptions: NoDuplicateHeadingsOptions, MessageIds: NoDuplicateHeadingsMessageIds }>} NoDuplicateHeadingsRuleDefinition
*/
//-----------------------------------------------------------------------------
// Rule Definition
//-----------------------------------------------------------------------------
export default /** @satisfies {NoDuplicateHeadingsRuleDefinition} */ ({
meta: {
type: "problem",
docs: {
description: "Disallow duplicate headings in the same document",
url: "https://github.com/eslint/markdown/blob/main/docs/rules/no-duplicate-headings.md",
},
messages: {
duplicateHeading: 'Duplicate heading "{{text}}" found.',
},
schema: [
{
type: "object",
properties: {
checkSiblingsOnly: {
type: "boolean",
},
},
additionalProperties: false,
},
],
defaultOptions: [{ checkSiblingsOnly: false }],
},
create(context) {
const [{ checkSiblingsOnly }] = context.options;
/** @type {Map<number, Set<string>>} */
const headingsByLevel = checkSiblingsOnly
? new Map([
[1, new Set()],
[2, new Set()],
[3, new Set()],
[4, new Set()],
[5, new Set()],
[6, new Set()],
])
: new Map([[1, new Set()]]);
let lastLevel = 1;
let currentLevelHeadings = headingsByLevel.get(lastLevel);
/** @type {string} */
let headingChildrenSequence;
/** @type {string} */
let headingText;
return {
heading(node) {
headingChildrenSequence = "";
headingText = "";
if (checkSiblingsOnly) {
const currentLevel = node.depth;
if (currentLevel < lastLevel) {
for (let level = lastLevel; level > currentLevel; level--) {
headingsByLevel.get(level).clear();
}
}
lastLevel = currentLevel;
currentLevelHeadings = headingsByLevel.get(currentLevel);
}
},
"heading *"({ type, value }) {
if (value) {
headingChildrenSequence += `[${type},${value}]`; // We use a custom sequence representation to keep track of heading children.
if (type !== "html") {
headingText += value;
}
}
else {
headingChildrenSequence += `[${type}]`;
}
},
"heading:exit"(node) {
if (currentLevelHeadings.has(headingChildrenSequence)) {
context.report({
loc: node.position,
messageId: "duplicateHeading",
data: {
text: headingText,
},
});
}
else {
currentLevelHeadings.add(headingChildrenSequence);
}
},
};
},
});
@@ -0,0 +1,64 @@
declare namespace _default {
namespace meta {
let type: "problem";
namespace docs {
let recommended: boolean;
let description: string;
let url: string;
}
namespace messages {
let emptyDefinition: string;
let emptyFootnoteDefinition: string;
}
let schema: {
type: "object";
properties: {
allowDefinitions: {
type: "array";
items: {
type: "string";
};
uniqueItems: true;
};
allowFootnoteDefinitions: {
type: "array";
items: {
type: "string";
};
uniqueItems: true;
};
checkFootnoteDefinitions: {
type: "boolean";
};
};
additionalProperties: false;
}[];
let defaultOptions: [{
allowDefinitions: string[];
allowFootnoteDefinitions: any[];
checkFootnoteDefinitions: true;
}];
}
function create(context: import("@eslint/core").RuleContext<{
LangOptions: import("../types.js").MarkdownLanguageOptions;
Code: import("../index.js").MarkdownSourceCode;
RuleOptions: NoEmptyDefinitionsOptions;
Node: import("mdast").Node | import("../language/markdown-source-code.js").InlineConfigComment;
MessageIds: NoEmptyDefinitionsMessageIds;
}>): {
definition(node: import("mdast").Definition): void;
footnoteDefinition(node: import("mdast").FootnoteDefinition): void;
};
}
export default _default;
export type NoEmptyDefinitionsMessageIds = "emptyDefinition" | "emptyFootnoteDefinition";
export type NoEmptyDefinitionsOptions = [{
allowDefinitions?: string[];
allowFootnoteDefinitions?: string[];
checkFootnoteDefinitions?: boolean;
}];
export type NoEmptyDefinitionsRuleDefinition = MarkdownRuleDefinition<{
RuleOptions: NoEmptyDefinitionsOptions;
MessageIds: NoEmptyDefinitionsMessageIds;
}>;
import type { MarkdownRuleDefinition } from "../types.js";
@@ -0,0 +1,115 @@
/**
* @fileoverview Rule to prevent empty definitions in Markdown.
* @author Pixel998
*/
//-----------------------------------------------------------------------------
// Imports
//-----------------------------------------------------------------------------
import { normalizeIdentifier } from "micromark-util-normalize-identifier";
import { htmlCommentPattern } from "../util.js";
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
* @import { MarkdownRuleDefinition } from "../types.js";
* @typedef {"emptyDefinition" | "emptyFootnoteDefinition"} NoEmptyDefinitionsMessageIds
* @typedef {[{ allowDefinitions?: string[], allowFootnoteDefinitions?: string[], checkFootnoteDefinitions?: boolean }]} NoEmptyDefinitionsOptions
* @typedef {MarkdownRuleDefinition<{ RuleOptions: NoEmptyDefinitionsOptions, MessageIds: NoEmptyDefinitionsMessageIds }>} NoEmptyDefinitionsRuleDefinition
*/
//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------
/**
* Checks if a string contains only HTML comments.
* @param {string} value The input string to check.
* @returns {boolean} True if the string contains only HTML comments, false otherwise.
*/
function isOnlyComments(value) {
const withoutComments = value.replace(htmlCommentPattern, "");
return withoutComments.trim().length === 0;
}
//-----------------------------------------------------------------------------
// Rule Definition
//-----------------------------------------------------------------------------
export default /** @satisfies {NoEmptyDefinitionsRuleDefinition} */ ({
meta: {
type: "problem",
docs: {
recommended: true,
description: "Disallow empty definitions",
url: "https://github.com/eslint/markdown/blob/main/docs/rules/no-empty-definitions.md",
},
messages: {
emptyDefinition: "Unexpected empty definition `{{ identifier }}` (label: `{{ label }}`) found.",
emptyFootnoteDefinition: "Unexpected empty footnote definition `{{ identifier }}` (label: `{{ label }}`) found.",
},
schema: [
{
type: "object",
properties: {
allowDefinitions: {
type: "array",
items: {
type: "string",
},
uniqueItems: true,
},
allowFootnoteDefinitions: {
type: "array",
items: {
type: "string",
},
uniqueItems: true,
},
checkFootnoteDefinitions: {
type: "boolean",
},
},
additionalProperties: false,
},
],
defaultOptions: [
{
allowDefinitions: ["//"],
allowFootnoteDefinitions: [],
checkFootnoteDefinitions: true,
},
],
},
create(context) {
const allowDefinitions = new Set(context.options[0].allowDefinitions.map(identifier => normalizeIdentifier(identifier).toLowerCase()));
const allowFootnoteDefinitions = new Set(context.options[0].allowFootnoteDefinitions.map(identifier => normalizeIdentifier(identifier).toLowerCase()));
const [{ checkFootnoteDefinitions }] = context.options;
return {
definition(node) {
if ((!node.url || node.url === "#") &&
!allowDefinitions.has(node.identifier)) {
context.report({
loc: node.position,
messageId: "emptyDefinition",
data: {
identifier: node.identifier,
label: node.label.trim(),
},
});
}
},
footnoteDefinition(node) {
if (checkFootnoteDefinitions &&
!allowFootnoteDefinitions.has(node.identifier) &&
(node.children.length === 0 ||
node.children.every(child => child.type === "html" &&
isOnlyComments(child.value)))) {
context.report({
loc: node.position,
messageId: "emptyFootnoteDefinition",
data: {
identifier: node.identifier,
label: node.label,
},
});
}
},
};
},
});
@@ -0,0 +1,30 @@
declare namespace _default {
namespace meta {
let type: "problem";
namespace docs {
let recommended: boolean;
let description: string;
let url: string;
}
namespace messages {
let emptyImage: string;
}
}
function create(context: import("@eslint/core").RuleContext<{
LangOptions: import("../types.js").MarkdownLanguageOptions;
Code: import("../index.js").MarkdownSourceCode;
RuleOptions: [];
Node: import("mdast").Node | import("../language/markdown-source-code.js").InlineConfigComment;
MessageIds: "emptyImage";
}>): {
image(node: import("mdast").Image): void;
};
}
export default _default;
export type NoEmptyImagesMessageIds = "emptyImage";
export type NoEmptyImagesOptions = [];
export type NoEmptyImagesRuleDefinition = MarkdownRuleDefinition<{
RuleOptions: NoEmptyImagesOptions;
MessageIds: NoEmptyImagesMessageIds;
}>;
import type { MarkdownRuleDefinition } from "../types.js";
@@ -0,0 +1,41 @@
/**
* @fileoverview Rule to prevent empty images in Markdown.
* @author 루밀LuMir(lumirlumir)
*/
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
* @import { MarkdownRuleDefinition } from "../types.js";
* @typedef {"emptyImage"} NoEmptyImagesMessageIds
* @typedef {[]} NoEmptyImagesOptions
* @typedef {MarkdownRuleDefinition<{ RuleOptions: NoEmptyImagesOptions, MessageIds: NoEmptyImagesMessageIds }>} NoEmptyImagesRuleDefinition
*/
//-----------------------------------------------------------------------------
// Rule Definition
//-----------------------------------------------------------------------------
export default /** @satisfies {NoEmptyImagesRuleDefinition} */ ({
meta: {
type: "problem",
docs: {
recommended: true,
description: "Disallow empty images",
url: "https://github.com/eslint/markdown/blob/main/docs/rules/no-empty-images.md",
},
messages: {
emptyImage: "Unexpected empty image found.",
},
},
create(context) {
return {
image(node) {
if (!node.url || node.url === "#") {
context.report({
loc: node.position,
messageId: "emptyImage",
});
}
},
};
},
});
@@ -0,0 +1,30 @@
declare namespace _default {
namespace meta {
let type: "problem";
namespace docs {
let recommended: boolean;
let description: string;
let url: string;
}
namespace messages {
let emptyLink: string;
}
}
function create(context: import("@eslint/core").RuleContext<{
LangOptions: import("../types.js").MarkdownLanguageOptions;
Code: import("../index.js").MarkdownSourceCode;
RuleOptions: [];
Node: import("mdast").Node | import("../language/markdown-source-code.js").InlineConfigComment;
MessageIds: "emptyLink";
}>): {
link(node: import("mdast").Link): void;
};
}
export default _default;
export type NoEmptyLinksMessageIds = "emptyLink";
export type NoEmptyLinksOptions = [];
export type NoEmptyLinksRuleDefinition = MarkdownRuleDefinition<{
RuleOptions: NoEmptyLinksOptions;
MessageIds: NoEmptyLinksMessageIds;
}>;
import type { MarkdownRuleDefinition } from "../types.js";
+41
View File
@@ -0,0 +1,41 @@
/**
* @fileoverview Rule to prevent empty links in Markdown.
* @author Nicholas C. Zakas
*/
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
* @import { MarkdownRuleDefinition } from "../types.js";
* @typedef {"emptyLink"} NoEmptyLinksMessageIds
* @typedef {[]} NoEmptyLinksOptions
* @typedef {MarkdownRuleDefinition<{ RuleOptions: NoEmptyLinksOptions, MessageIds: NoEmptyLinksMessageIds }>} NoEmptyLinksRuleDefinition
*/
//-----------------------------------------------------------------------------
// Rule Definition
//-----------------------------------------------------------------------------
export default /** @satisfies {NoEmptyLinksRuleDefinition} */ ({
meta: {
type: "problem",
docs: {
recommended: true,
description: "Disallow empty links",
url: "https://github.com/eslint/markdown/blob/main/docs/rules/no-empty-links.md",
},
messages: {
emptyLink: "Unexpected empty link found.",
},
},
create(context) {
return {
link(node) {
if (!node.url || node.url === "#") {
context.report({
loc: node.position,
messageId: "emptyLink",
});
}
},
};
},
});
+52
View File
@@ -0,0 +1,52 @@
declare namespace _default {
namespace meta {
let type: "problem";
namespace docs {
let description: string;
let url: string;
}
namespace messages {
let disallowedElement: string;
}
let schema: {
type: "object";
properties: {
allowed: {
type: "array";
items: {
type: "string";
};
uniqueItems: true;
};
allowedIgnoreCase: {
type: "boolean";
};
};
additionalProperties: false;
}[];
let defaultOptions: [{
allowed: any[];
allowedIgnoreCase: false;
}];
}
function create(context: import("@eslint/core").RuleContext<{
LangOptions: import("../types.js").MarkdownLanguageOptions;
Code: import("../index.js").MarkdownSourceCode;
RuleOptions: NoHtmlOptions;
Node: import("mdast").Node | import("../language/markdown-source-code.js").InlineConfigComment;
MessageIds: "disallowedElement";
}>): {
html(node: import("mdast").Html): void;
};
}
export default _default;
export type NoHtmlMessageIds = "disallowedElement";
export type NoHtmlOptions = [{
allowed?: string[];
allowedIgnoreCase?: boolean;
}];
export type NoHtmlRuleDefinition = MarkdownRuleDefinition<{
RuleOptions: NoHtmlOptions;
MessageIds: NoHtmlMessageIds;
}>;
import type { MarkdownRuleDefinition } from "../types.js";
+103
View File
@@ -0,0 +1,103 @@
/**
* @fileoverview Rule to disallow HTML inside of content.
* @author Nicholas C. Zakas
*/
//-----------------------------------------------------------------------------
// Imports
//-----------------------------------------------------------------------------
import { lineEndingPattern, stripHtmlComments } from "../util.js";
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
* @import { MarkdownRuleDefinition } from "../types.js";
* @typedef {"disallowedElement"} NoHtmlMessageIds
* @typedef {[{ allowed?: string[], allowedIgnoreCase?: boolean }]} NoHtmlOptions
* @typedef {MarkdownRuleDefinition<{ RuleOptions: NoHtmlOptions, MessageIds: NoHtmlMessageIds }>} NoHtmlRuleDefinition
*/
//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------
const htmlTagPattern = /<(?<tagName>[a-z0-9]+(?:-[a-z0-9]+)*)(?:\s(?:[^>"']|"[^"]*"|'[^']*')*)?\/?>/giu;
//-----------------------------------------------------------------------------
// Rule Definition
//-----------------------------------------------------------------------------
export default /** @satisfies {NoHtmlRuleDefinition} */ ({
meta: {
type: "problem",
docs: {
description: "Disallow HTML tags",
url: "https://github.com/eslint/markdown/blob/main/docs/rules/no-html.md",
},
messages: {
disallowedElement: 'HTML element "{{name}}" is not allowed.',
},
schema: [
{
type: "object",
properties: {
allowed: {
type: "array",
items: {
type: "string",
},
uniqueItems: true,
},
allowedIgnoreCase: {
type: "boolean",
},
},
additionalProperties: false,
},
],
defaultOptions: [
{
allowed: [],
allowedIgnoreCase: false,
},
],
},
create(context) {
const { sourceCode } = context;
const [{ allowed, allowedIgnoreCase }] = context.options;
/**
* Normalize a tag name based on the `allowedIgnoreCase` option.
* @param {string} tagName The tag name to normalize.
* @returns {string} The normalized tag name.
*/
function normalizeTagName(tagName) {
return allowedIgnoreCase ? tagName.toLowerCase() : tagName;
}
const allowedElements = new Set(allowed.map(normalizeTagName));
return {
html(node) {
const text = stripHtmlComments(sourceCode.getText(node));
/** @type {RegExpExecArray | null} */
let match;
while ((match = htmlTagPattern.exec(text)) !== null) {
const fullMatch = match[0];
const { tagName } = match.groups;
const firstNewlineIndex = fullMatch.search(lineEndingPattern);
const startOffset = // Adjust `htmlTagPattern` match index to the full source code.
match.index + node.position.start.offset;
const endOffset = startOffset +
(firstNewlineIndex === -1
? fullMatch.length
: firstNewlineIndex);
if (!allowedElements.has(normalizeTagName(tagName))) {
context.report({
loc: {
start: sourceCode.getLocFromIndex(startOffset),
end: sourceCode.getLocFromIndex(endOffset),
},
messageId: "disallowedElement",
data: {
name: tagName,
},
});
}
}
},
};
},
});
@@ -0,0 +1,32 @@
declare namespace _default {
namespace meta {
let type: "problem";
namespace docs {
let recommended: boolean;
let description: string;
let url: string;
}
namespace messages {
let invalidLabelRef: string;
}
}
function create(context: import("@eslint/core").RuleContext<{
LangOptions: import("../types.js").MarkdownLanguageOptions;
Code: MarkdownSourceCode;
RuleOptions: [];
Node: import("mdast").Node | import("../language/markdown-source-code.js").InlineConfigComment;
MessageIds: "invalidLabelRef";
}>): {
text(node: Text): void;
};
}
export default _default;
export type NoInvalidLabelRefsMessageIds = "invalidLabelRef";
export type NoInvalidLabelRefsOptions = [];
export type NoInvalidLabelRefsRuleDefinition = MarkdownRuleDefinition<{
RuleOptions: NoInvalidLabelRefsOptions;
MessageIds: NoInvalidLabelRefsMessageIds;
}>;
import type { MarkdownSourceCode } from "../language/markdown-source-code.js";
import type { Text } from "mdast";
import type { MarkdownRuleDefinition } from "../types.js";
@@ -0,0 +1,117 @@
/**
* @fileoverview Rule to prevent non-complaint link references.
* @author Nicholas C. Zakas
*/
//-----------------------------------------------------------------------------
// Imports
//-----------------------------------------------------------------------------
import { illegalShorthandTailPattern } from "../util.js";
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
* @import { Position } from "unist";
* @import { Text } from "mdast";
* @import { MarkdownRuleDefinition } from "../types.js";
* @import { MarkdownSourceCode } from "../language/markdown-source-code.js";
* @typedef {"invalidLabelRef"} NoInvalidLabelRefsMessageIds
* @typedef {[]} NoInvalidLabelRefsOptions
* @typedef {MarkdownRuleDefinition<{ RuleOptions: NoInvalidLabelRefsOptions, MessageIds: NoInvalidLabelRefsMessageIds }>} NoInvalidLabelRefsRuleDefinition
*/
//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------
/** matches i.e., `[foo][bar]` */
const labelPattern = /\]\[([^\]]+)\]/u;
/**
* Finds missing references in a node.
* @param {Text} node The node to check.
* @param {MarkdownSourceCode} sourceCode The Markdown source code object.
* @returns {Array<{label:string,position:Position}>} The missing references.
*/
function findInvalidLabelReferences(node, sourceCode) {
const nodeText = sourceCode.getText(node);
const docText = sourceCode.text;
const invalid = [];
let startIndex = 0;
/*
* This loop works by searching the string inside the node for the next
* label reference. If it finds one, it checks to see if there is any
* white space between the [ and ]. If there is, it reports an error.
* It then moves the start index to the end of the label reference and
* continues searching the text until the end of the text is found.
*/
while (startIndex < nodeText.length) {
const value = nodeText.slice(startIndex);
const match = value.match(labelPattern);
if (!match) {
break;
}
if (!illegalShorthandTailPattern.test(match[0])) {
startIndex += match.index + match[0].length;
continue;
}
/*
* Adjust `labelPattern` match index to the full source code.
*/
const startOffset = startIndex + match.index + node.position.start.offset;
const endOffset = startOffset + match[0].length;
/*
* Search the entire document text to find the preceding open bracket.
*/
const lastOpenBracketIndex = docText.lastIndexOf("[", startOffset);
if (lastOpenBracketIndex === -1) {
startIndex += match.index + match[0].length;
continue;
}
/*
* Note: `label` can contain leading and trailing newlines, so we need to
* take that into account when calculating the line and column offsets.
*/
const label = docText
.slice(lastOpenBracketIndex, endOffset)
.match(/!?\[([^\]]+)\]/u)[1];
invalid.push({
label: label.trim(),
position: {
start: sourceCode.getLocFromIndex(startOffset + 1),
end: sourceCode.getLocFromIndex(endOffset),
},
});
startIndex += match.index + match[0].length;
}
return invalid;
}
//-----------------------------------------------------------------------------
// Rule Definition
//-----------------------------------------------------------------------------
export default /** @satisfies {NoInvalidLabelRefsRuleDefinition} */ ({
meta: {
type: "problem",
docs: {
recommended: true,
description: "Disallow invalid label references",
url: "https://github.com/eslint/markdown/blob/main/docs/rules/no-invalid-label-refs.md",
},
messages: {
invalidLabelRef: "Label reference '{{label}}' is invalid due to white space between [ and ].",
},
},
create(context) {
const { sourceCode } = context;
return {
text(node) {
const invalidReferences = findInvalidLabelReferences(node, sourceCode);
for (const invalidReference of invalidReferences) {
context.report({
loc: invalidReference.position,
messageId: "invalidLabelRef",
data: {
label: invalidReference.label,
},
});
}
},
};
},
});
@@ -0,0 +1,46 @@
declare namespace _default {
namespace meta {
let type: "problem";
namespace docs {
let recommended: boolean;
let description: string;
let url: string;
}
let fixable: "whitespace";
namespace messages {
let missingSpace: string;
}
let schema: {
type: "object";
properties: {
checkClosedHeadings: {
type: "boolean";
};
};
additionalProperties: false;
}[];
let defaultOptions: [{
checkClosedHeadings: false;
}];
}
function create(context: import("@eslint/core").RuleContext<{
LangOptions: import("../types.js").MarkdownLanguageOptions;
Code: import("../index.js").MarkdownSourceCode;
RuleOptions: NoMissingAtxHeadingSpaceOptions;
Node: import("mdast").Node | import("../language/markdown-source-code.js").InlineConfigComment;
MessageIds: "missingSpace";
}>): {
heading(node: import("mdast").Heading): void;
paragraph(node: import("mdast").Paragraph): void;
};
}
export default _default;
export type NoMissingAtxHeadingSpaceMessageIds = "missingSpace";
export type NoMissingAtxHeadingSpaceOptions = [{
checkClosedHeadings?: boolean;
}];
export type NoMissingAtxHeadingSpaceRuleDefinition = MarkdownRuleDefinition<{
RuleOptions: NoMissingAtxHeadingSpaceOptions;
MessageIds: NoMissingAtxHeadingSpaceMessageIds;
}>;
import type { MarkdownRuleDefinition } from "../types.js";
@@ -0,0 +1,101 @@
/**
* @fileoverview Rule to ensure there is a space after hash on ATX style headings in Markdown.
* @author Sweta Tanwar (@SwetaTanwar)
*/
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
* @import { MarkdownRuleDefinition } from "../types.js";
* @typedef {"missingSpace"} NoMissingAtxHeadingSpaceMessageIds
* @typedef {[{ checkClosedHeadings?: boolean }]} NoMissingAtxHeadingSpaceOptions
* @typedef {MarkdownRuleDefinition<{ RuleOptions: NoMissingAtxHeadingSpaceOptions, MessageIds: NoMissingAtxHeadingSpaceMessageIds }>} NoMissingAtxHeadingSpaceRuleDefinition
*/
//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------
const leadingAtxHeadingHashPattern = /(?:^|(?<=[\r\n]))(?<hashes>#{1,6})(?:[^# \t]|$)/gu;
const trailingAtxHeadingHashPattern = /(?<![ \t])(?<spaces>[ \t]*)(?<=(?<!\\)(?:\\{2})*)(?<hashes>#+)[ \t]*$/u;
//-----------------------------------------------------------------------------
// Rule Definition
//-----------------------------------------------------------------------------
export default /** @satisfies {NoMissingAtxHeadingSpaceRuleDefinition} */ ({
meta: {
type: "problem",
docs: {
recommended: true,
description: "Disallow headings without a space after the hash characters",
url: "https://github.com/eslint/markdown/blob/main/docs/rules/no-missing-atx-heading-space.md",
},
fixable: "whitespace",
messages: {
missingSpace: "Missing space {{position}} hash(es) on ATX style heading.",
},
schema: [
{
type: "object",
properties: {
checkClosedHeadings: {
type: "boolean",
},
},
additionalProperties: false,
},
],
defaultOptions: [{ checkClosedHeadings: false }],
},
create(context) {
const { sourceCode } = context;
const [{ checkClosedHeadings }] = context.options;
return {
heading(node) {
if (!checkClosedHeadings) {
return;
}
const text = sourceCode.getText(node);
const match = trailingAtxHeadingHashPattern.exec(text);
if (match === null) {
return;
}
const { spaces, hashes } = match.groups;
if (spaces.length > 0) {
return;
}
const startOffset = node.position.start.offset + match.index;
const endOffset = startOffset + hashes.length;
context.report({
loc: {
start: sourceCode.getLocFromIndex(startOffset - 1),
end: sourceCode.getLocFromIndex(endOffset),
},
messageId: "missingSpace",
data: { position: "before" },
fix(fixer) {
return fixer.insertTextBeforeRange([startOffset, startOffset + 1], " ");
},
});
},
paragraph(node) {
const text = sourceCode.getText(node);
/** @type {RegExpExecArray | null} */
let match;
while ((match = leadingAtxHeadingHashPattern.exec(text)) !== null) {
const { hashes } = match.groups;
const startOffset = node.position.start.offset + match.index;
const endOffset = startOffset + hashes.length;
context.report({
loc: {
start: sourceCode.getLocFromIndex(startOffset),
end: sourceCode.getLocFromIndex(endOffset + 1),
},
messageId: "missingSpace",
data: { position: "after" },
fix(fixer) {
return fixer.insertTextAfterRange([endOffset - 1, endOffset], " ");
},
});
}
},
};
},
});
@@ -0,0 +1,52 @@
declare namespace _default {
namespace meta {
let type: "problem";
namespace docs {
let recommended: boolean;
let description: string;
let url: string;
}
let schema: {
type: "object";
properties: {
allowLabels: {
type: "array";
items: {
type: "string";
};
uniqueItems: true;
};
};
additionalProperties: false;
}[];
let defaultOptions: [{
allowLabels: any[];
}];
namespace messages {
let notFound: string;
}
}
function create(context: import("@eslint/core").RuleContext<{
LangOptions: import("../types.js").MarkdownLanguageOptions;
Code: MarkdownSourceCode;
RuleOptions: NoMissingLabelRefsOptions;
Node: import("mdast").Node | import("../language/markdown-source-code.js").InlineConfigComment;
MessageIds: "notFound";
}>): {
"root:exit"(): void;
text(node: Text): void;
definition(node: import("mdast").Definition): void;
};
}
export default _default;
export type NoMissingLabelRefsMessageIds = "notFound";
export type NoMissingLabelRefsOptions = [{
allowLabels?: string[];
}];
export type NoMissingLabelRefsRuleDefinition = MarkdownRuleDefinition<{
RuleOptions: NoMissingLabelRefsOptions;
MessageIds: NoMissingLabelRefsMessageIds;
}>;
import type { MarkdownSourceCode } from "../language/markdown-source-code.js";
import type { Text } from "mdast";
import type { MarkdownRuleDefinition } from "../types.js";
@@ -0,0 +1,147 @@
/**
* @fileoverview Rule to prevent missing label references in Markdown.
* @author Nicholas C. Zakas
*/
//-----------------------------------------------------------------------------
// Imports
//-----------------------------------------------------------------------------
import { illegalShorthandTailPattern } from "../util.js";
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
* @import { Position } from "unist";
* @import { Text } from "mdast";
* @import { MarkdownRuleDefinition } from "../types.js";
* @import { MarkdownSourceCode } from "../language/markdown-source-code.js";
* @typedef {"notFound"} NoMissingLabelRefsMessageIds
* @typedef {[{ allowLabels?: string[] }]} NoMissingLabelRefsOptions
* @typedef {MarkdownRuleDefinition<{ RuleOptions: NoMissingLabelRefsOptions, MessageIds: NoMissingLabelRefsMessageIds }>} NoMissingLabelRefsRuleDefinition
*/
//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------
/**
* Finds missing references in a node.
* @param {Text} node The node to check.
* @param {MarkdownSourceCode} sourceCode The Markdown source code object.
* @returns {Array<{label:string,position:Position}>} The missing references.
*/
function findMissingReferences(node, sourceCode) {
/** @type {Array<{label:string,position:Position}>} */
const missing = [];
const nodeText = sourceCode.getText(node);
/**
* Matches substrings like `"[foo]"`, `"[]"`, `"[foo][bar]"`, `"[foo][]"`, `"[][bar]"`, or `"[][]"`.
* `left` is the content between the first brackets. It can be empty.
* `right` is the content between the second brackets. It can be empty, and it can be undefined.
*/
const labelPattern = /(?<=(?<!\\)(?:\\{2})*)\[(?<left>(?:\\.|[^[\]\\])*)\](?:\[(?<right>(?:\\.|[^\]\\])*)\])?/dgu;
/** @type {RegExpExecArray | null} */
let match;
/*
* This loop searches the text inside the node for sequences that
* look like label references and reports an error for each one found.
*/
while ((match = labelPattern.exec(nodeText))) {
// skip illegal shorthand tail -- handled by no-invalid-label-refs
if (illegalShorthandTailPattern.test(match[0])) {
continue;
}
const { left, right } = match.groups;
// `[][]` or `[]`
if (!left && !right) {
continue;
}
let label, labelIndices;
if (right) {
label = right;
labelIndices = match.indices.groups.right;
}
else {
label = left;
labelIndices = match.indices.groups.left;
}
const startOffset = labelIndices[0] + node.position.start.offset;
const endOffset = labelIndices[1] + node.position.start.offset;
missing.push({
label: label.trim(),
position: {
start: sourceCode.getLocFromIndex(startOffset),
end: sourceCode.getLocFromIndex(endOffset),
},
});
}
return missing;
}
//-----------------------------------------------------------------------------
// Rule Definition
//-----------------------------------------------------------------------------
export default /** @satisfies {NoMissingLabelRefsRuleDefinition} */ ({
meta: {
type: "problem",
docs: {
recommended: true,
description: "Disallow missing label references",
url: "https://github.com/eslint/markdown/blob/main/docs/rules/no-missing-label-refs.md",
},
schema: [
{
type: "object",
properties: {
allowLabels: {
type: "array",
items: {
type: "string",
},
uniqueItems: true,
},
},
additionalProperties: false,
},
],
defaultOptions: [
{
allowLabels: [],
},
],
messages: {
notFound: "Label reference '{{label}}' not found.",
},
},
create(context) {
const { sourceCode } = context;
const allowLabels = new Set(context.options[0].allowLabels);
/** @type {Array<{label:string,position:Position}>} */
let allMissingReferences = [];
return {
"root:exit"() {
for (const missingReference of allMissingReferences) {
context.report({
loc: missingReference.position,
messageId: "notFound",
data: {
label: missingReference.label,
},
});
}
},
text(node) {
const missingReferences = findMissingReferences(node, sourceCode);
for (const missingReference of missingReferences) {
if (!allowLabels.has(missingReference.label)) {
allMissingReferences.push(missingReference);
}
}
},
definition(node) {
/*
* Sometimes a poorly-formatted link will end up a text node instead of a link node
* even though the label definition exists. Here, we remove any missing references
* that have a matching label definition.
*/
allMissingReferences = allMissingReferences.filter(missingReference => missingReference.label !== node.identifier);
},
};
},
});
@@ -0,0 +1,56 @@
declare namespace _default {
namespace meta {
let type: "problem";
namespace docs {
let recommended: boolean;
let description: string;
let url: string;
}
let schema: {
type: "object";
properties: {
ignoreCase: {
type: "boolean";
};
allowPattern: {
type: "string";
};
};
additionalProperties: false;
}[];
namespace messages {
let invalidFragment: string;
}
let defaultOptions: [{
ignoreCase: true;
allowPattern: string;
}];
}
function create(context: import("@eslint/core").RuleContext<{
LangOptions: import("../types.js").MarkdownLanguageOptions;
Code: import("../index.js").MarkdownSourceCode;
RuleOptions: NoMissingLinkFragmentsOptions;
Node: import("mdast").Node | import("../language/markdown-source-code.js").InlineConfigComment;
MessageIds: "invalidFragment";
}>): {
heading(): void;
"heading *:not(html)"({ value }: any): void;
"heading:exit"(): void;
html(node: import("mdast").Html): void;
"definition, link"(node: Definition | Link): void;
"root:exit"(): void;
};
}
export default _default;
export type NoMissingLinkFragmentsMessageIds = "invalidFragment";
export type NoMissingLinkFragmentsOptions = [{
ignoreCase?: boolean;
allowPattern?: string;
}];
export type NoMissingLinkFragmentsRuleDefinition = MarkdownRuleDefinition<{
RuleOptions: NoMissingLinkFragmentsOptions;
MessageIds: NoMissingLinkFragmentsMessageIds;
}>;
import type { Definition } from "mdast";
import type { Link } from "mdast";
import type { MarkdownRuleDefinition } from "../types.js";
@@ -0,0 +1,141 @@
/**
* @fileoverview Rule to ensure link fragments (URLs that start with #) reference valid headings
* @author Sweta Tanwar (@SwetaTanwar)
*/
//-----------------------------------------------------------------------------
// Imports
//-----------------------------------------------------------------------------
import GithubSlugger from "github-slugger";
import { stripHtmlComments } from "../util.js";
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
* @import { Definition, Link } from "mdast";
* @import { MarkdownRuleDefinition } from "../types.js";
* @typedef {"invalidFragment"} NoMissingLinkFragmentsMessageIds
* @typedef {[{ ignoreCase?: boolean; allowPattern?: string }]} NoMissingLinkFragmentsOptions
* @typedef {MarkdownRuleDefinition<{ RuleOptions: NoMissingLinkFragmentsOptions, MessageIds: NoMissingLinkFragmentsMessageIds }>} NoMissingLinkFragmentsRuleDefinition
*/
//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------
const githubLineReferencePattern = /^L\d+(?:C\d+)?(?:-L\d+(?:C\d+)?)?$/u;
const customHeadingIdPattern = /\{#(?<id>[^}\s]+)\}\s*$/u;
const htmlHeadingPattern = /<h(?<depth>[1-6])[^>]*>(?<children>[\s\S]*?)<\/h\k<depth>\s*>/giu;
const htmlIdNamePattern = /(?<!<)<[^>]+\s(?:id|name)\s*=\s*["']?(?<id>[^"'\s>]+)["']?/giu;
const htmlTagPattern = /<\/?[a-z0-9]+(?:-[a-z0-9]+)*(?:\s(?:[^>"']|"[^"]*"|'[^']*')*)?(?:\/\s*)?>/giu;
//-----------------------------------------------------------------------------
// Rule Definition
//-----------------------------------------------------------------------------
export default /** @satisfies {NoMissingLinkFragmentsRuleDefinition} */ ({
meta: {
type: "problem",
docs: {
recommended: true,
description: "Disallow link fragments that do not reference valid headings",
url: "https://github.com/eslint/markdown/blob/main/docs/rules/no-missing-link-fragments.md",
},
schema: [
{
type: "object",
properties: {
ignoreCase: {
type: "boolean",
},
allowPattern: {
type: "string",
},
},
additionalProperties: false,
},
],
messages: {
invalidFragment: "Link fragment '#{{fragment}}' does not reference a heading or anchor in this document.",
},
defaultOptions: [
{
ignoreCase: true,
allowPattern: "",
},
],
},
create(context) {
const [{ allowPattern, ignoreCase }] = context.options;
const allowPatternOrNull = allowPattern
? new RegExp(allowPattern, "u")
: null;
const fragmentIds = new Set(["top"]);
const slugger = new GithubSlugger();
/** @type {Array<Definition | Link>} */
const relevantNodes = [];
/** @type {string} */
let headingText;
return {
heading() {
headingText = "";
},
"heading *:not(html)"({ value }) {
headingText += value ?? "";
},
"heading:exit"() {
const customIdMatch = headingText.match(customHeadingIdPattern);
const id = customIdMatch
? customIdMatch.groups.id
: headingText;
fragmentIds.add(slugger.slug(id));
},
html(node) {
// 1. Remove all comments
const htmlTextWithoutComments = stripHtmlComments(node.value);
// 2. Then look for IDs in the remaining text
for (const match of htmlTextWithoutComments.matchAll(htmlIdNamePattern)) {
const { id } = match.groups;
fragmentIds.add(slugger.slug(id));
}
// 3. Finally, look for headings in the HTML
for (const match of htmlTextWithoutComments.matchAll(htmlHeadingPattern)) {
const { children } = match.groups;
// Remove any HTML tags within the heading content to get plain text
const id = children.replace(htmlTagPattern, "");
fragmentIds.add(slugger.slug(id));
}
},
"definition, link"(/** @type {Definition | Link} */ node) {
const { url } = node;
// If `url` is empty, `"#"`, or does not start with `"#"`, skip it.
if (url === "" || url === "#" || !url.startsWith("#")) {
return;
}
relevantNodes.push(node);
},
"root:exit"() {
for (const node of relevantNodes) {
const fragment = node.url.slice(1);
let decodedFragment = fragment;
// Decode URI component to handle encoded characters such as `%20`.
try {
decodedFragment = decodeURIComponent(fragment);
}
catch {
// If decoding fails due to an invalid URI sequence, use the original fragment.
}
if (allowPatternOrNull?.test(decodedFragment) ||
githubLineReferencePattern.test(decodedFragment)) {
continue;
}
const normalizedFragment = ignoreCase
? decodedFragment.toLowerCase()
: decodedFragment;
if (!fragmentIds.has(normalizedFragment)) {
context.report({
loc: node.position,
messageId: "invalidFragment",
data: { fragment },
});
}
}
},
};
},
});
@@ -0,0 +1,49 @@
declare namespace _default {
namespace meta {
let type: "problem";
namespace docs {
let recommended: boolean;
let description: string;
let url: string;
}
namespace messages {
let multipleH1: string;
}
let schema: {
type: "object";
properties: {
frontmatterTitle: {
type: "string";
};
};
additionalProperties: false;
}[];
let defaultOptions: [{
frontmatterTitle: string;
}];
}
function create(context: import("@eslint/core").RuleContext<{
LangOptions: import("../types.js").MarkdownLanguageOptions;
Code: import("../index.js").MarkdownSourceCode;
RuleOptions: NoMultipleH1Options;
Node: import("mdast").Node | import("../language/markdown-source-code.js").InlineConfigComment;
MessageIds: "multipleH1";
}>): {
"yaml, toml, json"(node: Yaml | Toml | Json): void;
html(node: import("mdast").Html): void;
heading(node: import("mdast").Heading): void;
};
}
export default _default;
export type NoMultipleH1MessageIds = "multipleH1";
export type NoMultipleH1Options = [{
frontmatterTitle?: string;
}];
export type NoMultipleH1RuleDefinition = MarkdownRuleDefinition<{
RuleOptions: NoMultipleH1Options;
MessageIds: NoMultipleH1MessageIds;
}>;
import type { Yaml } from "mdast";
import type { Toml } from "../types.js";
import type { Json } from "../types.js";
import type { MarkdownRuleDefinition } from "../types.js";
+98
View File
@@ -0,0 +1,98 @@
/**
* @fileoverview Rule to enforce at most one H1 heading in Markdown.
* @author Pixel998
*/
//-----------------------------------------------------------------------------
// Imports
//-----------------------------------------------------------------------------
import { frontmatterHasTitle, stripHtmlComments } from "../util.js";
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
* @import { Yaml } from "mdast";
* @import { MarkdownRuleDefinition, Toml, Json } from "../types.js";
* @typedef {"multipleH1"} NoMultipleH1MessageIds
* @typedef {[{ frontmatterTitle?: string }]} NoMultipleH1Options
* @typedef {MarkdownRuleDefinition<{ RuleOptions: NoMultipleH1Options, MessageIds: NoMultipleH1MessageIds }>} NoMultipleH1RuleDefinition
*/
//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------
const h1TagPattern = /<h1[^>]*>[\s\S]*?<\/h1\s*>/giu;
//-----------------------------------------------------------------------------
// Rule Definition
//-----------------------------------------------------------------------------
export default /** @satisfies {NoMultipleH1RuleDefinition} */ ({
meta: {
type: "problem",
docs: {
recommended: true,
description: "Disallow multiple H1 headings in the same document",
url: "https://github.com/eslint/markdown/blob/main/docs/rules/no-multiple-h1.md",
},
messages: {
multipleH1: "Unexpected additional H1 heading found.",
},
schema: [
{
type: "object",
properties: {
frontmatterTitle: {
type: "string",
},
},
additionalProperties: false,
},
],
defaultOptions: [
{
frontmatterTitle: "^(?!\\s*['\"]title[:=]['\"])\\s*\\{?\\s*['\"]?title['\"]?\\s*[:=]",
},
],
},
create(context) {
const { sourceCode } = context;
const [{ frontmatterTitle }] = context.options;
const titlePattern = frontmatterTitle === "" ? null : new RegExp(frontmatterTitle, "iu");
let h1Count = 0;
return {
"yaml, toml, json"(/** @type {Yaml | Toml | Json} */ node) {
if (frontmatterHasTitle(node.value, titlePattern)) {
h1Count++;
}
},
html(node) {
const text = stripHtmlComments(node.value);
/** @type {RegExpExecArray | null} */
let match;
while ((match = h1TagPattern.exec(text)) !== null) {
h1Count++;
if (h1Count > 1) {
const startOffset = // Adjust `h1TagPattern` match index to the full source code.
match.index + node.position.start.offset;
const endOffset = startOffset + match[0].length;
context.report({
loc: {
start: sourceCode.getLocFromIndex(startOffset),
end: sourceCode.getLocFromIndex(endOffset),
},
messageId: "multipleH1",
});
}
}
},
heading(node) {
if (node.depth === 1) {
h1Count++;
if (h1Count > 1) {
context.report({
loc: node.position,
messageId: "multipleH1",
});
}
}
},
};
},
});
@@ -0,0 +1,35 @@
declare namespace _default {
namespace meta {
let type: "problem";
namespace docs {
let recommended: boolean;
let description: string;
let url: string;
}
let fixable: "code";
namespace messages {
let referenceLikeUrl: string;
}
}
function create(context: import("@eslint/core").RuleContext<{
LangOptions: import("../types.js").MarkdownLanguageOptions;
Code: import("../index.js").MarkdownSourceCode;
RuleOptions: [];
Node: import("mdast").Node | import("../language/markdown-source-code.js").InlineConfigComment;
MessageIds: "referenceLikeUrl";
}>): {
definition(node: import("mdast").Definition): void;
"image, link"(node: Image | Link): void;
"root:exit"(): void;
};
}
export default _default;
export type NoReferenceLikeUrlsMessageIds = "referenceLikeUrl";
export type NoReferenceLikeUrlsOptions = [];
export type NoReferenceLikeUrlsRuleDefinition = MarkdownRuleDefinition<{
RuleOptions: NoReferenceLikeUrlsOptions;
MessageIds: NoReferenceLikeUrlsMessageIds;
}>;
import type { Image } from "mdast";
import type { Link } from "mdast";
import type { MarkdownRuleDefinition } from "../types.js";
@@ -0,0 +1,84 @@
/**
* @fileoverview Rule to enforce reference-style links when URL matches a defined identifier.
* @author TKDev7
*/
//-----------------------------------------------------------------------------
// Imports
//-----------------------------------------------------------------------------
import { normalizeIdentifier } from "micromark-util-normalize-identifier";
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
* @import { Image, Link } from "mdast";
* @import { MarkdownRuleDefinition } from "../types.js";
* @typedef {"referenceLikeUrl"} NoReferenceLikeUrlsMessageIds
* @typedef {[]} NoReferenceLikeUrlsOptions
* @typedef {MarkdownRuleDefinition<{ RuleOptions: NoReferenceLikeUrlsOptions, MessageIds: NoReferenceLikeUrlsMessageIds }>} NoReferenceLikeUrlsRuleDefinition
*/
//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------
/** Pattern to match both inline links: `[text](url)` and images: `![alt](url)`, with optional title */
const linkOrImagePattern = /\[(?<label>(?:\\.|[^()\\]|\([\s\S]*\))*?)\]\((?<destination>[ \t]*\r?\n?(?<![ \t])[ \t]*(?:<[^>]*>|[^ \t()]+))(?:[ \t]*\r?\n?(?<![ \t])[ \t]*(?:"[^"]*"|'[^']*'|\([^)]*\)))?[ \t]*\r?\n?(?<![ \t])[ \t]*\)$/u;
//-----------------------------------------------------------------------------
// Rule Definition
//-----------------------------------------------------------------------------
export default /** @satisfies {NoReferenceLikeUrlsRuleDefinition} */ ({
meta: {
type: "problem",
docs: {
recommended: true,
description: "Disallow URLs that match defined reference identifiers",
url: "https://github.com/eslint/markdown/blob/main/docs/rules/no-reference-like-urls.md",
},
fixable: "code",
messages: {
referenceLikeUrl: "Unexpected resource {{type}} ('{{prefix}}[text](url)') with URL that matches a definition identifier. Use '[text][id]' syntax instead.",
},
},
create(context) {
const { sourceCode } = context;
/** @type {Set<string>} */
const definitionIdentifiers = new Set();
/** @type {Array<Image | Link>} */
const relevantNodes = [];
return {
definition(node) {
definitionIdentifiers.add(node.identifier);
},
"image, link"(/** @type {Image | Link} */ node) {
relevantNodes.push(node);
},
"root:exit"() {
for (const node of relevantNodes) {
const text = sourceCode.getText(node);
const match = linkOrImagePattern.exec(text);
if (match !== null) {
const { label, destination } = match.groups;
const { type, title } = node;
const prefix = type === "image" ? "!" : "";
const url = normalizeIdentifier(destination).toLowerCase();
if (definitionIdentifiers.has(url)) {
context.report({
loc: node.position,
messageId: "referenceLikeUrl",
data: {
type,
prefix,
},
fix(fixer) {
// The AST treats both missing and empty titles as null, so it's safe to auto-fix in both cases.
if (title) {
return null;
}
return fixer.replaceText(node, `${prefix}[${label}][${destination}]`);
},
});
}
}
}
},
};
},
});
@@ -0,0 +1,42 @@
declare namespace _default {
namespace meta {
let type: "problem";
namespace docs {
let recommended: boolean;
let description: string;
let url: string;
}
let fixable: "code";
namespace messages {
let reversedSyntax: string;
}
}
function create(context: import("@eslint/core").RuleContext<{
LangOptions: import("../types.js").MarkdownLanguageOptions;
Code: import("../index.js").MarkdownSourceCode;
RuleOptions: [];
Node: import("mdast").Node | import("../language/markdown-source-code.js").InlineConfigComment;
MessageIds: "reversedSyntax";
}>): {
"heading, paragraph, tableCell"(node: Heading | Paragraph | TableCell): void;
":matches(heading, paragraph, tableCell) :matches(html, image, imageReference, inlineCode, linkReference, inlineMath)"(node: Html | Image | ImageReference | InlineCode | LinkReference | InlineMath): void;
":matches(heading, paragraph, tableCell):exit"(): void;
};
}
export default _default;
export type NoReversedMediaSyntaxMessageIds = "reversedSyntax";
export type NoReversedMediaSyntaxOptions = [];
export type NoReversedMediaSyntaxRuleDefinition = MarkdownRuleDefinition<{
RuleOptions: NoReversedMediaSyntaxOptions;
MessageIds: NoReversedMediaSyntaxMessageIds;
}>;
import type { Heading } from "mdast";
import type { Paragraph } from "mdast";
import type { TableCell } from "mdast";
import type { Html } from "mdast";
import type { Image } from "mdast";
import type { ImageReference } from "mdast";
import type { InlineCode } from "mdast";
import type { LinkReference } from "mdast";
import type { InlineMath } from "mdast-util-math";
import type { MarkdownRuleDefinition } from "../types.js";
@@ -0,0 +1,85 @@
/**
* @fileoverview Rule to prevent reversed link and image syntax in Markdown.
* @author xbinaryx
*/
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
* @import { Heading, Paragraph, TableCell, Html, Image, ImageReference, InlineCode, LinkReference } from "mdast";
* @import { InlineMath } from "mdast-util-math";
* @import { MarkdownRuleDefinition } from "../types.js";
* @typedef {"reversedSyntax"} NoReversedMediaSyntaxMessageIds
* @typedef {[]} NoReversedMediaSyntaxOptions
* @typedef {MarkdownRuleDefinition<{ RuleOptions: NoReversedMediaSyntaxOptions, MessageIds: NoReversedMediaSyntaxMessageIds }>} NoReversedMediaSyntaxRuleDefinition
*/
//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------
/** Matches reversed link/image syntax like `(text)[url]`, ignoring escaped characters like `\(text\)[url]`. */
const reversedPattern = /(?<=(?<!\\)(?:\\{2})*)\((?<label>(?:\\.|[^()\\]|\([\s\S]*\))*)\)\[(?<url>(?:\\.|[^\]\\\r\n])*)\](?!\()/gu;
//-----------------------------------------------------------------------------
// Rule Definition
//-----------------------------------------------------------------------------
export default /** @satisfies {NoReversedMediaSyntaxRuleDefinition} */ ({
meta: {
type: "problem",
docs: {
recommended: true,
description: "Disallow reversed link and image syntax",
url: "https://github.com/eslint/markdown/blob/main/docs/rules/no-reversed-media-syntax.md",
},
fixable: "code",
messages: {
reversedSyntax: "Unexpected reversed syntax found. Use [label](URL) syntax instead.",
},
},
create(context) {
const { sourceCode } = context;
/** @type {string[]} */
let buffer;
/** @type {number} */
let nodeStartOffset;
return {
"heading, paragraph, tableCell"(
/** @type {Heading | Paragraph | TableCell} */ node) {
// Use UTF-16 code units so the buffer stays aligned with source offsets.
buffer = sourceCode.getText(node).split("");
// Store the start offset of the node for later calculations.
nodeStartOffset = node.position.start.offset;
},
":matches(heading, paragraph, tableCell) :matches(html, image, imageReference, inlineCode, linkReference, inlineMath)"(
/** @type {Html | Image | ImageReference | InlineCode | LinkReference | InlineMath} */ node) {
const [startOffset, endOffset] = sourceCode.getRange(node);
// Mask the content of `html`, `image`, `imageReference`, `inlineCode`, `linkReference`, and `inlineMath` nodes with whitespaces.
for (let i = startOffset; i < endOffset; i++) {
buffer[i - nodeStartOffset] = " ";
}
},
":matches(heading, paragraph, tableCell):exit"() {
const maskedText = buffer.join("");
/** @type {RegExpExecArray | null} */
let match;
while ((match = reversedPattern.exec(maskedText)) !== null) {
const { label, url } = match.groups;
const startOffset = match.index + nodeStartOffset; // Adjust `reversedPattern` match index to the full source code.
const endOffset = startOffset + match[0].length;
const labelStartOffset = startOffset + 1; // Skip "("
const labelEndOffset = labelStartOffset + label.length;
const urlStartOffset = labelEndOffset + 2; // Skip ")["
const urlEndOffset = urlStartOffset + url.length;
context.report({
loc: {
start: sourceCode.getLocFromIndex(startOffset),
end: sourceCode.getLocFromIndex(endOffset),
},
messageId: "reversedSyntax",
fix(fixer) {
return fixer.replaceTextRange([startOffset, endOffset], `[${sourceCode.text.slice(labelStartOffset, labelEndOffset)}](${sourceCode.text.slice(urlStartOffset, urlEndOffset)})`);
},
});
}
},
};
},
});
@@ -0,0 +1,51 @@
declare namespace _default {
namespace meta {
let type: "problem";
namespace docs {
let recommended: boolean;
let description: string;
let url: string;
}
let fixable: "whitespace";
namespace messages {
let spaceInEmphasis: string;
}
let schema: {
type: "object";
properties: {
checkStrikethrough: {
type: "boolean";
};
};
additionalProperties: false;
}[];
let defaultOptions: [{
checkStrikethrough: false;
}];
}
function create(context: import("@eslint/core").RuleContext<{
LangOptions: import("../types.js").MarkdownLanguageOptions;
Code: import("../index.js").MarkdownSourceCode;
RuleOptions: NoSpaceInEmphasisOptions;
Node: import("mdast").Node | import("../language/markdown-source-code.js").InlineConfigComment;
MessageIds: "spaceInEmphasis";
}>): {
"heading, paragraph, tableCell"(node: Heading | Paragraph | TableCell): void;
":matches(heading, paragraph, tableCell) > text"(node: Text): void;
":matches(heading, paragraph, tableCell):exit"(node: Heading | Paragraph | TableCell): void;
};
}
export default _default;
export type NoSpaceInEmphasisMessageIds = "spaceInEmphasis";
export type NoSpaceInEmphasisOptions = [{
checkStrikethrough?: boolean;
}];
export type NoSpaceInEmphasisRuleDefinition = MarkdownRuleDefinition<{
RuleOptions: NoSpaceInEmphasisOptions;
MessageIds: NoSpaceInEmphasisMessageIds;
}>;
import type { Heading } from "mdast";
import type { Paragraph } from "mdast";
import type { TableCell } from "mdast";
import type { Text } from "mdast";
import type { MarkdownRuleDefinition } from "../types.js";
@@ -0,0 +1,134 @@
/**
* @fileoverview Rule to prevent spaces around emphasis markers in Markdown.
* @author Pixel998
*/
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
* @import { SourceRange } from "@eslint/core";
* @import { Heading, Paragraph, TableCell, Text } from "mdast";
* @import { MarkdownRuleDefinition } from "../types.js";
* @typedef {"spaceInEmphasis"} NoSpaceInEmphasisMessageIds
* @typedef {[{ checkStrikethrough?: boolean }]} NoSpaceInEmphasisOptions
* @typedef {MarkdownRuleDefinition<{ RuleOptions: NoSpaceInEmphasisOptions, MessageIds: NoSpaceInEmphasisMessageIds }>} NoSpaceInEmphasisRuleDefinition
*/
//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------
const whitespacePattern = /[ \t]/u;
/**
* Creates a marker pattern based on whether strikethrough should be included.
* @param {boolean} checkStrikethrough Whether to include strikethrough markers.
* @returns {RegExp} The marker pattern.
*/
function createMarkerPattern(checkStrikethrough) {
return checkStrikethrough
? /(?<=(?<!\\)(?:\\{2})*)(?:\*\*\*|\*\*|\*|___|__|_|~~|~)/gu
: /(?<=(?<!\\)(?:\\{2})*)(?:\*\*\*|\*\*|\*|___|__|_)/gu;
}
//-----------------------------------------------------------------------------
// Rule Definition
//-----------------------------------------------------------------------------
export default /** @satisfies {NoSpaceInEmphasisRuleDefinition} */ ({
meta: {
type: "problem",
docs: {
recommended: true,
description: "Disallow spaces around emphasis markers",
url: "https://github.com/eslint/markdown/blob/main/docs/rules/no-space-in-emphasis.md",
},
fixable: "whitespace",
messages: {
spaceInEmphasis: "Unexpected space around emphasis marker.",
},
schema: [
{
type: "object",
properties: {
checkStrikethrough: {
type: "boolean",
},
},
additionalProperties: false,
},
],
defaultOptions: [
{
checkStrikethrough: false,
},
],
},
create(context) {
const { sourceCode } = context;
const [{ checkStrikethrough }] = context.options;
const markerPattern = createMarkerPattern(checkStrikethrough);
/** @type {string[]} */
let buffer;
/**
* Reports a surrounding-space violation if present.
* @param {number} checkIndex Character index to test for whitespace.
* @param {number} highlightStartIndex Start index for highlighting.
* @param {number} highlightEndIndex End index for highlighting.
* @returns {void}
*/
function reportWhitespace(checkIndex, highlightStartIndex, highlightEndIndex) {
if (whitespacePattern.test(sourceCode.text[checkIndex])) {
context.report({
loc: {
start: sourceCode.getLocFromIndex(highlightStartIndex),
end: sourceCode.getLocFromIndex(highlightEndIndex),
},
messageId: "spaceInEmphasis",
fix(fixer) {
return fixer.removeRange([checkIndex, checkIndex + 1]);
},
});
}
}
return {
"heading, paragraph, tableCell"(
/** @type {Heading | Paragraph | TableCell} */ node) {
const [startOffset, endOffset] = sourceCode.getRange(node);
// Initialize `buffer` with a whitespace-masked character array.
buffer = new Array(endOffset - startOffset).fill(" ");
},
":matches(heading, paragraph, tableCell) > text"(
/** @type {Text} */ node) {
const [startOffset, endOffset] = sourceCode.getRange(node);
const parentNodeStartOffset = // Parent node can be `Heading`, `Paragraph`, or `TableCell`.
sourceCode.getParent(node).position.start.offset;
// Add the content of a `Text` node into the current buffer at the correct offsets.
for (let i = startOffset; i < endOffset; i++) {
buffer[i - parentNodeStartOffset] = sourceCode.text[i];
}
},
":matches(heading, paragraph, tableCell):exit"(
/** @type {Heading | Paragraph | TableCell} */ node) {
const maskedText = buffer.join("");
/** @type {Map<string, SourceRange[]>} */
const markerGroups = new Map();
/** @type {RegExpExecArray | null} */
let match;
while ((match = markerPattern.exec(maskedText)) !== null) {
const marker = match[0];
const startOffset = // Adjust `markerPattern` match index to the full source code.
match.index + node.position.start.offset;
const endOffset = startOffset + marker.length;
if (!markerGroups.has(marker)) {
markerGroups.set(marker, []);
}
markerGroups.get(marker).push([startOffset, endOffset]);
}
for (const group of markerGroups.values()) {
for (let i = 0; i < group.length - 1; i += 2) {
const startMarker = group[i];
reportWhitespace(startMarker[1], startMarker[0], startMarker[1] + 2);
const endMarker = group[i + 1];
reportWhitespace(endMarker[0] - 1, endMarker[0] - 2, endMarker[1]);
}
}
},
};
},
});
@@ -0,0 +1,70 @@
declare namespace _default {
namespace meta {
let type: "problem";
namespace docs {
let recommended: boolean;
let description: string;
let url: string;
}
namespace messages {
let unusedDefinition: string;
let unusedFootnoteDefinition: string;
}
let schema: {
type: "object";
properties: {
allowDefinitions: {
type: "array";
items: {
type: "string";
};
uniqueItems: true;
};
allowFootnoteDefinitions: {
type: "array";
items: {
type: "string";
};
uniqueItems: true;
};
checkFootnoteDefinitions: {
type: "boolean";
};
};
additionalProperties: false;
}[];
let defaultOptions: [{
allowDefinitions: string[];
allowFootnoteDefinitions: any[];
checkFootnoteDefinitions: true;
}];
}
function create(context: import("@eslint/core").RuleContext<{
LangOptions: import("../types.js").MarkdownLanguageOptions;
Code: import("../index.js").MarkdownSourceCode;
RuleOptions: NoUnusedDefinitionsOptions;
Node: import("mdast").Node | import("../language/markdown-source-code.js").InlineConfigComment;
MessageIds: NoUnusedDefinitionsMessageIds;
}>): {
imageReference(node: import("mdast").ImageReference): void;
linkReference(node: import("mdast").LinkReference): void;
footnoteReference(node: import("mdast").FootnoteReference): void;
definition(node: Definition): void;
footnoteDefinition(node: FootnoteDefinition): void;
"root:exit"(): void;
};
}
export default _default;
export type NoUnusedDefinitionsMessageIds = "unusedDefinition" | "unusedFootnoteDefinition";
export type NoUnusedDefinitionsOptions = [{
allowDefinitions?: string[];
allowFootnoteDefinitions?: string[];
checkFootnoteDefinitions?: boolean;
}];
export type NoUnusedDefinitionsRuleDefinition = MarkdownRuleDefinition<{
RuleOptions: NoUnusedDefinitionsOptions;
MessageIds: NoUnusedDefinitionsMessageIds;
}>;
import type { Definition } from "mdast";
import type { FootnoteDefinition } from "mdast";
import type { MarkdownRuleDefinition } from "../types.js";
@@ -0,0 +1,130 @@
/**
* @fileoverview Rule to prevent unused definitions in Markdown.
* @author 루밀LuMir(lumirlumir)
*/
//-----------------------------------------------------------------------------
// Imports
//-----------------------------------------------------------------------------
import { normalizeIdentifier } from "micromark-util-normalize-identifier";
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
* @import { Definition, FootnoteDefinition } from "mdast";
* @import { MarkdownRuleDefinition } from "../types.js";
* @typedef {"unusedDefinition" | "unusedFootnoteDefinition"} NoUnusedDefinitionsMessageIds
* @typedef {[{ allowDefinitions?: string[], allowFootnoteDefinitions?: string[], checkFootnoteDefinitions?: boolean }]} NoUnusedDefinitionsOptions
* @typedef {MarkdownRuleDefinition<{ RuleOptions: NoUnusedDefinitionsOptions, MessageIds: NoUnusedDefinitionsMessageIds }>} NoUnusedDefinitionsRuleDefinition
*/
//-----------------------------------------------------------------------------
// Rule Definition
//-----------------------------------------------------------------------------
export default /** @satisfies {NoUnusedDefinitionsRuleDefinition} */ ({
meta: {
type: "problem",
docs: {
recommended: true,
description: "Disallow unused definitions",
url: "https://github.com/eslint/markdown/blob/main/docs/rules/no-unused-definitions.md",
},
messages: {
unusedDefinition: "Unexpected unused definition `{{ identifier }}` (label: `{{ label }}`) found.",
unusedFootnoteDefinition: "Unexpected unused footnote definition `{{ identifier }}` (label: `{{ label }}`) found.",
},
schema: [
{
type: "object",
properties: {
allowDefinitions: {
type: "array",
items: {
type: "string",
},
uniqueItems: true,
},
allowFootnoteDefinitions: {
type: "array",
items: {
type: "string",
},
uniqueItems: true,
},
checkFootnoteDefinitions: {
type: "boolean",
},
},
additionalProperties: false,
},
],
defaultOptions: [
{
allowDefinitions: ["//"],
allowFootnoteDefinitions: [],
checkFootnoteDefinitions: true,
},
],
},
create(context) {
const allowDefinitions = new Set(context.options[0].allowDefinitions.map(identifier => normalizeIdentifier(identifier).toLowerCase()));
const allowFootnoteDefinitions = new Set(context.options[0].allowFootnoteDefinitions.map(identifier => normalizeIdentifier(identifier).toLowerCase()));
const [{ checkFootnoteDefinitions }] = context.options;
/** @type {Set<string>} Set to track used identifiers */
const usedIdentifiers = new Set();
/** @type {Set<string>} Set to track used footnote identifiers */
const usedFootnoteIdentifiers = new Set();
/** @type {Set<Definition>} */
const definitions = new Set();
/** @type {Set<FootnoteDefinition>} */
const footnoteDefinitions = new Set();
return {
imageReference(node) {
usedIdentifiers.add(node.identifier);
},
linkReference(node) {
usedIdentifiers.add(node.identifier);
},
footnoteReference(node) {
usedFootnoteIdentifiers.add(node.identifier);
},
definition(node) {
if (allowDefinitions.has(node.identifier)) {
return;
}
definitions.add(node);
},
footnoteDefinition(node) {
if (!checkFootnoteDefinitions ||
allowFootnoteDefinitions.has(node.identifier)) {
return;
}
footnoteDefinitions.add(node);
},
"root:exit"() {
for (const definition of definitions) {
if (!usedIdentifiers.has(definition.identifier)) {
context.report({
node: definition,
messageId: "unusedDefinition",
data: {
identifier: definition.identifier,
label: definition.label.trim(),
},
});
}
}
for (const footnoteDefinition of footnoteDefinitions) {
if (!usedFootnoteIdentifiers.has(footnoteDefinition.identifier)) {
context.report({
node: footnoteDefinition,
messageId: "unusedFootnoteDefinition",
data: {
identifier: footnoteDefinition.identifier,
label: footnoteDefinition.label,
},
});
}
}
},
};
},
});
@@ -0,0 +1,33 @@
declare namespace _default {
namespace meta {
let type: "problem";
namespace docs {
let recommended: boolean;
let description: string;
let url: string;
}
namespace messages {
let altTextRequired: string;
}
}
function create(context: import("@eslint/core").RuleContext<{
LangOptions: import("../types.js").MarkdownLanguageOptions;
Code: import("../index.js").MarkdownSourceCode;
RuleOptions: [];
Node: import("mdast").Node | import("../language/markdown-source-code.js").InlineConfigComment;
MessageIds: "altTextRequired";
}>): {
"image, imageReference"(node: Image | ImageReference): void;
html(node: import("mdast").Html): void;
};
}
export default _default;
export type RequireAltTextMessageIds = "altTextRequired";
export type RequireAltTextOptions = [];
export type RequireAltTextRuleDefinition = MarkdownRuleDefinition<{
RuleOptions: RequireAltTextOptions;
MessageIds: RequireAltTextMessageIds;
}>;
import type { Image } from "mdast";
import type { ImageReference } from "mdast";
import type { MarkdownRuleDefinition } from "../types.js";
@@ -0,0 +1,90 @@
/**
* @fileoverview Rule to require alternative text for images in Markdown.
* @author Pixel998
*/
//-----------------------------------------------------------------------------
// Imports
//-----------------------------------------------------------------------------
import { stripHtmlComments } from "../util.js";
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
* @import { Image, ImageReference } from "mdast";
* @import { MarkdownRuleDefinition } from "../types.js";
* @typedef {"altTextRequired"} RequireAltTextMessageIds
* @typedef {[]} RequireAltTextOptions
* @typedef {MarkdownRuleDefinition<{ RuleOptions: RequireAltTextOptions, MessageIds: RequireAltTextMessageIds }>} RequireAltTextRuleDefinition
*/
//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------
const imgTagPattern = /<img(?:\s(?:[^>"']|"[^"]*"|'[^']*')*)?\/?>/giu;
/**
* Creates a regex to match HTML attributes
* @param {string} name The attribute name to match
* @returns {RegExp} Regular expression for matching the attribute
*/
function getHtmlAttributeRe(name) {
return new RegExp(`\\s${name}(?:\\s*=\\s*['"]([^'"]*)['"])?`, "iu");
}
//-----------------------------------------------------------------------------
// Rule Definition
//-----------------------------------------------------------------------------
export default /** @satisfies {RequireAltTextRuleDefinition} */ ({
meta: {
type: "problem",
docs: {
recommended: true,
description: "Require alternative text for images",
url: "https://github.com/eslint/markdown/blob/main/docs/rules/require-alt-text.md",
},
messages: {
altTextRequired: "Alternative text for image is required.",
},
},
create(context) {
const { sourceCode } = context;
return {
"image, imageReference"(
/** @type {Image | ImageReference} */ node) {
if (node.alt.trim().length === 0) {
context.report({
loc: node.position,
messageId: "altTextRequired",
});
}
},
html(node) {
const text = stripHtmlComments(sourceCode.getText(node));
/** @type {RegExpExecArray | null} */
let match;
while ((match = imgTagPattern.exec(text)) !== null) {
const imgTag = match[0];
const ariaHiddenMatch = imgTag.match(getHtmlAttributeRe("aria-hidden"));
if (ariaHiddenMatch &&
ariaHiddenMatch[1] &&
ariaHiddenMatch[1].toLowerCase() === "true") {
continue;
}
const altMatch = imgTag.match(getHtmlAttributeRe("alt"));
if (!altMatch ||
(altMatch[1] &&
altMatch[1].trim().length === 0 &&
altMatch[1].length > 0)) {
const startOffset = // Adjust `imgTagPattern` match indices to the full source code.
match.index + node.position.start.offset;
const endOffset = startOffset + imgTag.length;
context.report({
loc: {
start: sourceCode.getLocFromIndex(startOffset),
end: sourceCode.getLocFromIndex(endOffset),
},
messageId: "altTextRequired",
});
}
}
},
};
},
});
@@ -0,0 +1,45 @@
declare namespace _default {
namespace meta {
let type: "problem";
namespace docs {
let recommended: boolean;
let description: string;
let url: string;
}
namespace messages {
let extraCells: string;
let missingCells: string;
}
let schema: {
type: "object";
properties: {
checkMissingCells: {
type: "boolean";
};
};
additionalProperties: false;
}[];
let defaultOptions: [{
checkMissingCells: false;
}];
}
function create(context: import("@eslint/core").RuleContext<{
LangOptions: import("../types.js").MarkdownLanguageOptions;
Code: import("../index.js").MarkdownSourceCode;
RuleOptions: TableColumnCountOptions;
Node: import("mdast").Node | import("../language/markdown-source-code.js").InlineConfigComment;
MessageIds: TableColumnCountMessageIds;
}>): {
table(node: import("mdast").Table): void;
};
}
export default _default;
export type TableColumnCountMessageIds = "extraCells" | "missingCells";
export type TableColumnCountOptions = [{
checkMissingCells?: boolean;
}];
export type TableColumnCountRuleDefinition = MarkdownRuleDefinition<{
RuleOptions: TableColumnCountOptions;
MessageIds: TableColumnCountMessageIds;
}>;
import type { MarkdownRuleDefinition } from "../types.js";
@@ -0,0 +1,91 @@
/**
* @fileoverview Rule to disallow data rows in a GitHub Flavored Markdown table from having more cells than the header row
* @author Sweta Tanwar (@SwetaTanwar)
*/
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
* @import { MarkdownRuleDefinition } from "../types.js";
* @typedef {"extraCells" | "missingCells"} TableColumnCountMessageIds
* @typedef {[{ checkMissingCells?: boolean }]} TableColumnCountOptions
* @typedef {MarkdownRuleDefinition<{ RuleOptions: TableColumnCountOptions, MessageIds: TableColumnCountMessageIds }>} TableColumnCountRuleDefinition
*/
//-----------------------------------------------------------------------------
// Rule Definition
//-----------------------------------------------------------------------------
export default /** @satisfies {TableColumnCountRuleDefinition} */ ({
meta: {
type: "problem",
docs: {
recommended: true,
description: "Disallow data rows in a GitHub Flavored Markdown table from having more cells than the header row",
url: "https://github.com/eslint/markdown/blob/main/docs/rules/table-column-count.md",
},
messages: {
extraCells: "Table column count mismatch (Expected: {{expectedCells}}, Actual: {{actualCells}}), extra data starting here will be ignored.",
missingCells: "Table column count mismatch (Expected: {{expectedCells}}, Actual: {{actualCells}}), row might be missing data.",
},
schema: [
{
type: "object",
properties: {
checkMissingCells: {
type: "boolean",
},
},
additionalProperties: false,
},
],
defaultOptions: [{ checkMissingCells: false }],
},
create(context) {
const [{ checkMissingCells }] = context.options;
return {
table(node) {
if (node.children.length < 1) {
return;
}
const headerRow = node.children[0];
const expectedCellsLength = headerRow.children.length;
for (let i = 1; i < node.children.length; i++) {
const currentRow = node.children[i];
const actualCellsLength = currentRow.children.length;
const lastActualCellNode = currentRow.children[actualCellsLength - 1];
if (actualCellsLength > expectedCellsLength) {
const firstExtraCellNode = currentRow.children[expectedCellsLength];
context.report({
loc: {
start: firstExtraCellNode.position.start,
end: lastActualCellNode.position.end,
},
messageId: "extraCells",
data: {
actualCells: actualCellsLength,
expectedCells: expectedCellsLength,
},
});
}
else if (checkMissingCells &&
actualCellsLength < expectedCellsLength) {
context.report({
loc: {
start: {
column: lastActualCellNode.position.end.column -
1,
line: lastActualCellNode.position.end.line,
},
end: currentRow.position.end,
},
messageId: "missingCells",
data: {
actualCells: actualCellsLength,
expectedCells: expectedCellsLength,
},
});
}
}
},
};
},
});
+87
View File
@@ -0,0 +1,87 @@
import type { Node, Data, Literal, Parent, Blockquote, Break, Code, Definition, Emphasis, Heading, Html, Image, ImageReference, InlineCode, Link, LinkReference, List, ListItem, Paragraph, Root, Strong, Text, ThematicBreak, Delete, FootnoteDefinition, FootnoteReference, Table, TableCell, TableRow, Yaml } from "mdast";
import type { InlineMath, Math } from "mdast-util-math";
import type { LanguageContext, LanguageOptions, RuleVisitor } from "@eslint/core";
import type { CustomRuleDefinitionType, CustomRuleTypeDefinitions, CustomRuleVisitorWithExit } from "@eslint/plugin-kit";
import type { InlineConfigComment, MarkdownSourceCode } from "./language/markdown-source-code.js";
export interface RangeMap {
indent: number;
js: number;
md: number;
}
export interface BlockBase {
baseIndentText: string;
comments: string[];
rangeMap: RangeMap[];
}
export type Block = Code & BlockBase;
/**
* Markdown TOML.
*/
export interface Toml extends Literal {
/**
* Node type of mdast TOML.
*/
type: "toml";
/**
* Data associated with the mdast TOML.
*/
data?: TomlData | undefined;
}
/**
* Info associated with mdast TOML nodes by the ecosystem.
*/
export interface TomlData extends Data {
}
/**
* Markdown JSON.
*/
export interface Json extends Literal {
/**
* Node type of mdast JSON.
*/
type: "json";
/**
* Data associated with the mdast JSON.
*/
data?: JsonData | undefined;
}
/**
* Info associated with mdast JSON nodes by the ecosystem.
*/
export interface JsonData extends Data {
}
/**
* Language options provided for Markdown files.
*/
export interface MarkdownLanguageOptions extends LanguageOptions {
/**
* The options for parsing frontmatter.
*/
frontmatter?: false | "yaml" | "toml" | "json";
/**
* The options for parsing math.
*/
math?: boolean;
}
/**
* The context object that is passed to the Markdown language plugin methods.
*/
export type MarkdownLanguageContext = LanguageContext<MarkdownLanguageOptions>;
/**
* A Markdown syntax element, including nodes and comments.
*/
type MarkdownSyntaxElement = Node | InlineConfigComment;
export interface MarkdownRuleVisitor extends RuleVisitor, CustomRuleVisitorWithExit<{
root?(node: Root): void;
} & {
[NodeType in Blockquote | Break | Code | Definition | Emphasis | Heading | Html | Image | ImageReference | InlineCode | Link | LinkReference | List | ListItem | Paragraph | Strong | Text | ThematicBreak | Delete | FootnoteDefinition | FootnoteReference | Table | TableCell | TableRow | Yaml | Toml | Json | InlineMath | Math as NodeType["type"]]?: (node: NodeType, parent?: Parent) => void;
}> {
}
export type MarkdownRuleDefinitionTypeOptions = CustomRuleTypeDefinitions;
export type MarkdownRuleDefinition<Options extends Partial<MarkdownRuleDefinitionTypeOptions> = {}> = CustomRuleDefinitionType<{
LangOptions: MarkdownLanguageOptions;
Code: MarkdownSourceCode;
Visitor: MarkdownRuleVisitor;
Node: MarkdownSyntaxElement;
}, Options>;
export {};
+4
View File
@@ -0,0 +1,4 @@
//------------------------------------------------------------------------------
// Imports
//------------------------------------------------------------------------------
export {};
+35
View File
@@ -0,0 +1,35 @@
/**
* Checks if a frontmatter block contains a title matching the given pattern.
* @param {string} value The frontmatter content.
* @param {RegExp|null} pattern The pattern to match against.
* @returns {boolean} Whether a title was found.
*/
export function frontmatterHasTitle(value: string, pattern: RegExp | null): boolean;
/**
* Replaces all HTML comments with whitespace.
* This preserves offsets and locations of characters
* outside HTML comments by keeping line breaks and replacing
* other code units with a space character.
* @param {string} value The string to remove HTML comments from.
* @returns {string} The string with HTML comments removed.
*/
export function stripHtmlComments(value: string): string;
/**
* @fileoverview Utility Library
* @author Nicholas C. Zakas
*/
/**
* Line ending pattern to match all line endings (CRLF, CR, LF). (CommonMark spec)
* @see https://spec.commonmark.org/0.31.2/#line-ending
*/
export const lineEndingPattern: RegExp;
/**
* CommonMark does not allow any white space between the brackets in a reference link.
* If that pattern is detected, then it's treated as text and not as a link. This pattern
* is used to detect that situation.
*/
export const illegalShorthandTailPattern: RegExp;
/**
* Regular expression to match HTML comments, including multiline comments.
*/
export const htmlCommentPattern: RegExp;
+58
View File
@@ -0,0 +1,58 @@
/**
* @fileoverview Utility Library
* @author Nicholas C. Zakas
*/
//-----------------------------------------------------------------------------
// Regex Patterns
//-----------------------------------------------------------------------------
/**
* Line ending pattern to match all line endings (CRLF, CR, LF). (CommonMark spec)
* @see https://spec.commonmark.org/0.31.2/#line-ending
*/
export const lineEndingPattern = /\r\n|[\r\n]/u;
/**
* CommonMark does not allow any white space between the brackets in a reference link.
* If that pattern is detected, then it's treated as text and not as a link. This pattern
* is used to detect that situation.
*/
export const illegalShorthandTailPattern = /\]\[\s+\]$/u;
/**
* Regular expression to match HTML comments, including multiline comments.
*/
export const htmlCommentPattern = /<!--[\s\S]*?-->/gu;
//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------
/**
* Checks if a frontmatter block contains a title matching the given pattern.
* @param {string} value The frontmatter content.
* @param {RegExp|null} pattern The pattern to match against.
* @returns {boolean} Whether a title was found.
*/
export function frontmatterHasTitle(value, pattern) {
if (!pattern) {
return false;
}
const lines = value.split(lineEndingPattern);
for (const line of lines) {
if (pattern.test(line)) {
return true;
}
}
return false;
}
/**
* Replaces all HTML comments with whitespace.
* This preserves offsets and locations of characters
* outside HTML comments by keeping line breaks and replacing
* other code units with a space character.
* @param {string} value The string to remove HTML comments from.
* @returns {string} The string with HTML comments removed.
*/
export function stripHtmlComments(value) {
return value.replace(htmlCommentPattern, match =>
/* eslint-disable-next-line require-unicode-regexp
-- we want to replace each code unit with a space
*/
match.replace(/[^\r\n]/g, " "));
}