diff --git a/.gitignore b/.gitignore index 7a56a13be..cfc43fa20 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ demo/markdownlint-browser.js demo/markdownlint-browser.min.js node_modules !test/node_modules +!test/rules/node_modules npm-debug.log test-repos .DS_Store diff --git a/lib/defer-require.cjs b/lib/defer-require.cjs new file mode 100644 index 000000000..dc87ebc4d --- /dev/null +++ b/lib/defer-require.cjs @@ -0,0 +1,16 @@ +// @ts-check + +"use strict"; + +/** + * Calls require for markdownit.cjs. Used to synchronously defer loading because module.createRequire is buggy under webpack (https://github.com/webpack/webpack/issues/16724). + * + * @returns {any} Exported module content. + */ +function requireMarkdownItCjs() { + return require("./markdownit.cjs"); +} + +module.exports = { + requireMarkdownItCjs +}; diff --git a/lib/exports.d.mts b/lib/exports.d.mts index 74f6dab1a..4d93c971b 100644 --- a/lib/exports.d.mts +++ b/lib/exports.d.mts @@ -1,3 +1,4 @@ +export { resolveModule } from "./resolve-module.cjs"; export type Configuration = import("./markdownlint.mjs").Configuration; export type ConfigurationParser = import("./markdownlint.mjs").ConfigurationParser; export type ConfigurationStrict = import("./markdownlint.mjs").ConfigurationStrict; diff --git a/lib/exports.mjs b/lib/exports.mjs index 8fdef2e2a..1cfa0c02b 100644 --- a/lib/exports.mjs +++ b/lib/exports.mjs @@ -1,6 +1,7 @@ // @ts-check export { applyFix, applyFixes, getVersion } from "./markdownlint.mjs"; +export { resolveModule } from "./resolve-module.cjs"; /** @typedef {import("./markdownlint.mjs").Configuration} Configuration */ /** @typedef {import("./markdownlint.mjs").ConfigurationParser} ConfigurationParser */ diff --git a/lib/markdownlint.mjs b/lib/markdownlint.mjs index 8ae980196..7930cb837 100644 --- a/lib/markdownlint.mjs +++ b/lib/markdownlint.mjs @@ -1,10 +1,11 @@ // @ts-check // @ts-ignore -import { fs as nodeFs, module, os, path } from "#node-imports"; -const dynamicRequire = module.createRequire(import.meta.url); +import { fs as nodeFs, os, path } from "#node-imports"; import { initialize as cacheInitialize } from "./cache.mjs"; import { version } from "./constants.mjs"; +import { requireMarkdownItCjs } from "./defer-require.cjs"; +import { resolveModule } from "./resolve-module.cjs"; import rules from "./rules.mjs"; import { parse as micromarkParse } from "./micromark-parse.mjs"; import * as helpers from "../helpers/helpers.cjs"; @@ -501,7 +502,7 @@ function lintContent( // Parse content into lines and get markdown-it tokens const lines = content.split(helpers.newLineRe); const markdownitTokens = needMarkdownItTokens ? - dynamicRequire("./markdownit.cjs").getMarkdownItTokens(markdownItPlugins, preClearedContent, lines) : + requireMarkdownItCjs().getMarkdownItTokens(markdownItPlugins, preClearedContent, lines) : []; // Create (frozen) parameters for rules /** @type {MarkdownParsers} */ @@ -1010,10 +1011,10 @@ function resolveConfigExtends(configFile, referenceId, fs, callback) { if (err) { // Not a file, try require.resolve try { - return callback(null, dynamicRequire.resolve( - referenceId, - { "paths": [ configFileDirname ] } - )); + return callback( + null, + resolveModule(referenceId, [ configFileDirname ]) + ); } catch { // Unable to resolve, use resolvedExtendsFile } @@ -1041,10 +1042,7 @@ function resolveConfigExtendsSync(configFile, referenceId, fs) { // Not a file, try require.resolve } try { - return dynamicRequire.resolve( - referenceId, - { "paths": [ configFileDirname ] } - ); + return resolveModule(referenceId, [ configFileDirname ]); } catch { // Unable to resolve, return resolvedExtendsFile } diff --git a/lib/node-imports-browser-module.cjs b/lib/node-imports-browser-module.cjs deleted file mode 100644 index a48161571..000000000 --- a/lib/node-imports-browser-module.cjs +++ /dev/null @@ -1,7 +0,0 @@ -// @ts-check - -"use strict"; - -module.exports = { - "createRequire": () => require -}; diff --git a/lib/node-imports-browser.mjs b/lib/node-imports-browser.mjs index ecd8d01ff..33dfd78ec 100644 --- a/lib/node-imports-browser.mjs +++ b/lib/node-imports-browser.mjs @@ -12,8 +12,6 @@ export const fs = { "readFileSync": throwForSync }; -export { default as module } from "./node-imports-browser-module.cjs"; - export const os = {}; export const path = { diff --git a/lib/node-imports-node.mjs b/lib/node-imports-node.mjs index 694165776..4427e97ab 100644 --- a/lib/node-imports-node.mjs +++ b/lib/node-imports-node.mjs @@ -3,9 +3,6 @@ import { access, accessSync, readFile, readFileSync } from "node:fs"; export const fs = { access, accessSync, readFile, readFileSync }; -import { createRequire } from "node:module"; -export const module = { createRequire }; - import { EOL, homedir } from "node:os"; export const os = { EOL, homedir }; diff --git a/lib/resolve-module.cjs b/lib/resolve-module.cjs new file mode 100644 index 000000000..82922a87f --- /dev/null +++ b/lib/resolve-module.cjs @@ -0,0 +1,52 @@ +// @ts-check + +"use strict"; + +// @ts-ignore +// eslint-disable-next-line camelcase, no-inline-comments, no-undef +const nativeRequire = (typeof __non_webpack_require__ === "undefined") ? require : /* c8 ignore next */ __non_webpack_require__; +// Captures the native require implementation (even under webpack). + +/** + * @typedef RequireResolveOptions + * @property {string[]} [paths] Additional paths to resolve from. + */ + +/** + * @callback RequireResolve + * @param {string} id Module name or path. + * @param {RequireResolveOptions} options Options to apply. + * @returns {string} Resolved module path. + */ + +/** + * Resolves modules according to Node's resolution rules. + * + * @param {RequireResolve} resolve Node-like require.resolve implementation. + * @param {string} id Module name or path. + * @param {string[]} [paths] Additional paths to resolve from. + * @returns {string} Resolved module path. + */ +const resolveModuleCustomResolve = (resolve, id, paths = []) => { + // resolve.paths is sometimes not present under webpack or VS Code + // @ts-ignore + const resolvePaths = resolve.paths?.("") || []; + const allPaths = [ ...paths, ...resolvePaths ]; + return resolve(id, { "paths": allPaths }); +}; + +/** + * Resolves modules according to Node's resolution rules. + * + * @param {string} id Module name or path. + * @param {string[]} [paths] Additional paths to resolve from. + * @returns {string} Resolved module path. + */ +const resolveModule = (id, paths) => ( + resolveModuleCustomResolve(nativeRequire.resolve, id, paths) +); + +module.exports = { + resolveModule, + resolveModuleCustomResolve +}; diff --git a/lib/resolve-module.d.cts b/lib/resolve-module.d.cts new file mode 100644 index 000000000..f3d6a8458 --- /dev/null +++ b/lib/resolve-module.d.cts @@ -0,0 +1,34 @@ +export type RequireResolveOptions = { + /** + * Additional paths to resolve from. + */ + paths?: string[]; +}; +export type RequireResolve = (id: string, options: RequireResolveOptions) => string; +/** + * Resolves modules according to Node's resolution rules. + * + * @param {string} id Module name or path. + * @param {string[]} [paths] Additional paths to resolve from. + * @returns {string} Resolved module path. + */ +export function resolveModule(id: string, paths?: string[]): string; +/** + * @typedef RequireResolveOptions + * @property {string[]} [paths] Additional paths to resolve from. + */ +/** + * @callback RequireResolve + * @param {string} id Module name or path. + * @param {RequireResolveOptions} options Options to apply. + * @returns {string} Resolved module path. + */ +/** + * Resolves modules according to Node's resolution rules. + * + * @param {RequireResolve} resolve Node-like require.resolve implementation. + * @param {string} id Module name or path. + * @param {string[]} [paths] Additional paths to resolve from. + * @returns {string} Resolved module path. + */ +export function resolveModuleCustomResolve(resolve: RequireResolve, id: string, paths?: string[]): string; diff --git a/package.json b/package.json index 4f6445b4b..70e6e1dfa 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "build-config": "npm run build-config-schema && npm run build-config-example", "build-config-example": "node schema/build-config-example.mjs", "build-config-schema": "node schema/build-config-schema.mjs", - "build-declaration": "tsc --allowJs --checkJs --declaration --emitDeclarationOnly --module nodenext --outDir dts --rootDir . --target es2015 lib/exports.mjs lib/exports-async.mjs lib/exports-promise.mjs lib/exports-sync.mjs lib/markdownlint.mjs && node scripts/index.mjs copy dts/lib/exports.d.mts lib/exports.d.mts && node scripts/index.mjs copy dts/lib/exports-async.d.mts lib/exports-async.d.mts && node scripts/index.mjs copy dts/lib/exports-promise.d.mts lib/exports-promise.d.mts && node scripts/index.mjs copy dts/lib/exports-sync.d.mts lib/exports-sync.d.mts && node scripts/index.mjs copy dts/lib/markdownlint.d.mts lib/markdownlint.d.mts && node scripts/index.mjs remove dts", + "build-declaration": "tsc --allowJs --checkJs --declaration --emitDeclarationOnly --module nodenext --outDir dts --rootDir . --target es2015 lib/exports.mjs lib/exports-async.mjs lib/exports-promise.mjs lib/exports-sync.mjs lib/markdownlint.mjs lib/resolve-module.cjs && node scripts/index.mjs copy dts/lib/exports.d.mts lib/exports.d.mts && node scripts/index.mjs copy dts/lib/exports-async.d.mts lib/exports-async.d.mts && node scripts/index.mjs copy dts/lib/exports-promise.d.mts lib/exports-promise.d.mts && node scripts/index.mjs copy dts/lib/exports-sync.d.mts lib/exports-sync.d.mts && node scripts/index.mjs copy dts/lib/markdownlint.d.mts lib/markdownlint.d.mts && node scripts/index.mjs copy dts/lib/resolve-module.d.cts lib/resolve-module.d.cts && node scripts/index.mjs remove dts", "build-demo": "node scripts/index.mjs copy node_modules/markdown-it/dist/markdown-it.min.js demo/markdown-it.min.js && cd demo && webpack --no-stats", "build-docs": "node doc-build/build-rules.mjs", "build-example": "npm install --no-save --ignore-scripts grunt grunt-cli gulp through2", @@ -59,7 +59,7 @@ "lint-test-repos": "ava --timeout=10m test/markdownlint-test-repos-*.mjs", "serial-config-docs": "npm run build-config && npm run build-docs", "serial-declaration-demo": "npm run build-declaration && npm-run-all --continue-on-error --parallel build-demo test-declaration", - "test": "ava --timeout=30s test/markdownlint-test.mjs test/markdownlint-test-config.mjs test/markdownlint-test-custom-rules.mjs test/markdownlint-test-fixes.mjs test/markdownlint-test-helpers.mjs test/markdownlint-test-micromark.mjs test/markdownlint-test-result-object.mjs test/markdownlint-test-scenarios.mjs helpers/test.cjs", + "test": "ava --timeout=30s test/markdownlint-test.mjs test/markdownlint-test-config.mjs test/markdownlint-test-custom-rules.mjs test/markdownlint-test-fixes.mjs test/markdownlint-test-helpers.mjs test/markdownlint-test-micromark.mjs test/markdownlint-test-result-object.mjs test/markdownlint-test-scenarios.mjs test/resolve-module-test.mjs helpers/test.cjs", "test-cover": "c8 --100 npm test", "test-declaration": "cd example/typescript && tsc --module commonjs && tsc --module nodenext && node type-check.js", "test-extra": "ava --timeout=10m test/markdownlint-test-extra-parse.mjs test/markdownlint-test-extra-type.mjs", diff --git a/test/resolve-module-test.mjs b/test/resolve-module-test.mjs new file mode 100644 index 000000000..bc8080918 --- /dev/null +++ b/test/resolve-module-test.mjs @@ -0,0 +1,147 @@ +// @ts-check + +import test from "ava"; +import path from "node:path"; +import { __dirname as getDirname } from "./esm-helpers.mjs"; +import { resolveModule, resolveModuleCustomResolve } from "../lib/resolve-module.cjs"; + +import { createRequire } from "node:module"; +const require = createRequire(import.meta.url); +// eslint-disable-next-line no-underscore-dangle +const __dirname = getDirname(import.meta); + +test("built-in module", (t) => { + t.plan(1); + t.deepEqual( + resolveModule("node:fs"), + require.resolve("node:fs") + ); +}); + +test("locally-installed module", (t) => { + t.plan(1); + t.deepEqual( + resolveModule("micromark"), + require.resolve("micromark") + ); +}); + +test("absolute path to module", (t) => { + t.plan(1); + const absolute = + path.resolve( + __dirname, + "./rules/node_modules/markdownlint-rule-sample-commonjs" + ); + t.deepEqual( + resolveModule(absolute), + require.resolve(absolute) + ); +}); + +test("relative (to __dirname) path to module", (t) => { + t.plan(1); + t.deepEqual( + resolveModule( + "./rules/node_modules/markdownlint-rule-sample-module", + // __dirname is needed because require.resolve is relative to this + // file while resolveModule is relative to resolve-module.cjs + [ __dirname ] + ), + require.resolve( + "./rules/node_modules/markdownlint-rule-sample-module" + ) + ); +}); + +test("module in alternate node_modules", (t) => { + t.plan(3); + t.throws( + () => require.resolve("markdownlint-rule-sample-commonjs"), + { "code": "MODULE_NOT_FOUND" } + ); + t.throws( + () => resolveModule("markdownlint-rule-sample-commonjs"), + { "code": "MODULE_NOT_FOUND" } + ); + t.deepEqual( + resolveModule( + "markdownlint-rule-sample-commonjs", + [ path.join(__dirname, "rules") ] + ), + require.resolve( + "markdownlint-rule-sample-commonjs", + { "paths": [ path.join(__dirname, "rules") ] } + ) + ); +}); + +test("module local, relative, and in alternate node_modules (same paths)", (t) => { + t.plan(3); + const paths = [ + __dirname, + path.join(__dirname, "rules") + ]; + t.deepEqual( + resolveModule( + "micromark", + paths + ), + require.resolve( + "micromark", + { paths } + ) + ); + t.deepEqual( + resolveModule( + "./rules/node_modules/markdownlint-rule-sample-commonjs", + paths + ), + require.resolve( + "./rules/node_modules/markdownlint-rule-sample-commonjs", + { paths } + ) + ); + t.deepEqual( + resolveModule( + "markdownlint-rule-sample-commonjs", + paths + ), + require.resolve( + "markdownlint-rule-sample-commonjs", + { paths } + ) + ); +}); + +test("custom resolve implementation", (t) => { + t.plan(1); + const expected = + require.resolve("./rules/node_modules/markdownlint-rule-sample-module"); + const customResolve = (id, options) => require.resolve(id, options); + customResolve.paths = (request) => require.resolve.paths(request); + t.deepEqual( + resolveModuleCustomResolve( + customResolve, + "./rules/node_modules/markdownlint-rule-sample-module", + [ __dirname ] + ), + expected + ); +}); + +test("custom resolve implementation, missing paths", (t) => { + t.plan(1); + const expected = + require.resolve("./rules/node_modules/markdownlint-rule-sample-commonjs"); + const customResolve = (id, options) => require.resolve(id, options); + t.deepEqual( + resolveModuleCustomResolve( + // @ts-ignore + customResolve, + "./rules/node_modules/markdownlint-rule-sample-commonjs", + [ __dirname ] + ), + expected + ); +}); diff --git a/test/rules/node_modules/markdownlint-rule-sample-commonjs/package.json b/test/rules/node_modules/markdownlint-rule-sample-commonjs/package.json new file mode 100644 index 000000000..2af87edf2 --- /dev/null +++ b/test/rules/node_modules/markdownlint-rule-sample-commonjs/package.json @@ -0,0 +1,14 @@ +{ + "name": "markdownlint-rule-sample-commonjs", + "version": "0.0.1", + "description": "Package for markdownlint custom rule sample (commonjs)", + "main": "sample-rule.cjs", + "type": "commonjs", + "author": "David Anson (https://dlaa.me/)", + "homepage": "https://github.com/DavidAnson/markdownlint", + "license": "MIT", + "keywords": [ + "markdownlint-rule" + ], + "private": true +} diff --git a/test/rules/node_modules/markdownlint-rule-sample-commonjs/sample-rule.cjs b/test/rules/node_modules/markdownlint-rule-sample-commonjs/sample-rule.cjs new file mode 100644 index 000000000..7ab665f4d --- /dev/null +++ b/test/rules/node_modules/markdownlint-rule-sample-commonjs/sample-rule.cjs @@ -0,0 +1,18 @@ +// @ts-check + +"use strict"; + +module.exports = { + "names": [ "sample-rule-commonjs" ], + "description": "Sample rule (commonjs)", + "tags": [ "sample" ], + "function": (params, onError) => { + const hrTokens = params.tokens.filter((token) => token.type === "hr"); + for (const token of hrTokens) { + onError({ + "lineNumber": token.lineNumber, + "detail": "Sample error for hr" + }); + } + } +}; diff --git a/test/rules/node_modules/markdownlint-rule-sample-module/package.json b/test/rules/node_modules/markdownlint-rule-sample-module/package.json new file mode 100644 index 000000000..ccb3eff77 --- /dev/null +++ b/test/rules/node_modules/markdownlint-rule-sample-module/package.json @@ -0,0 +1,14 @@ +{ + "name": "markdownlint-rule-sample-module", + "version": "0.0.1", + "description": "Package for markdownlint custom rule sample (module)", + "main": "sample-rule.mjs", + "type": "module", + "author": "David Anson (https://dlaa.me/)", + "homepage": "https://github.com/DavidAnson/markdownlint", + "license": "MIT", + "keywords": [ + "markdownlint-rule" + ], + "private": true +} diff --git a/test/rules/node_modules/markdownlint-rule-sample-module/sample-rule.mjs b/test/rules/node_modules/markdownlint-rule-sample-module/sample-rule.mjs new file mode 100644 index 000000000..32aeeab18 --- /dev/null +++ b/test/rules/node_modules/markdownlint-rule-sample-module/sample-rule.mjs @@ -0,0 +1,18 @@ +// @ts-check + +const rule = { + "names": [ "sample-rule-module" ], + "description": "Sample rule (module)", + "tags": [ "sample" ], + "function": (params, onError) => { + const hrTokens = params.tokens.filter((token) => token.type === "hr"); + for (const token of hrTokens) { + onError({ + "lineNumber": token.lineNumber, + "detail": "Sample error for hr" + }); + } + } +}; + +export default rule;