@nitra/eslint-config
Version:
An ESLint shareable config for projects using Vue and Node
214 lines (200 loc) • 9.61 kB
JavaScript
/**
* Обхід обмеження graphql-eslint: loadOnDiskGraphQLConfig(filePath) для віртуальних
* шляхів виду …/Component.vue/…/script.js має отримувати шлях до фізичного .vue, інакше rootDir
* стає некоректним і eslint не бачить схему з .graphqlrc.yml.
* @see upstream graphql-eslint (MIT) on GitHub
*/
import { relative } from 'node:path'
import { env } from 'node:process'
import { CodeFileLoader } from '@graphql-tools/code-file-loader'
import { gqlPluckFromCodeStringSync } from '@graphql-tools/graphql-tag-pluck'
import { asArray } from '@graphql-tools/utils'
import graphqlEslintPlugin from '@graphql-eslint/eslint-plugin'
import { loadOnDiskGraphQLConfig } from '@graphql-eslint/eslint-plugin/graphql-config'
import { version } from '@graphql-eslint/eslint-plugin/meta'
import { CWD, REPORT_ON_FIRST_CHARACTER } from '@graphql-eslint/eslint-plugin/utils'
/**
* Дефолтні негативні globs для CodeFileLoader: апстрім `createGlobbyOptions` хардкодить
* `ignore: []`, тож широкі patterns у `.graphqlrc.yml documents` (наприклад,
* `**\/*.{vue,js,ts,tsx}`) сканують `node_modules` синхронно через Babel pluck і вішають
* eslint на хвилини. Patches `_buildGlobs` додає `!`-префіксні globs у сам список патернів —
* це працює, на відміну від спроби пропатчити free function `createGlobbyOptions`.
*/
const CODE_FILE_LOADER_DEFAULT_IGNORES = [
'!**/node_modules/**',
'!**/.git/**',
'!**/dist/**',
'!**/.yarn/**',
'!**/.cursor/**',
'!**/.claude/**',
'!**/.github/**',
'!**/.vscode/**',
'!**/coverage/**'
]
const CODE_FILE_LOADER_PATCH_FLAG = Symbol.for('@nitra/eslint-config/code-file-loader-default-ignores')
if (!CodeFileLoader.prototype[CODE_FILE_LOADER_PATCH_FLAG]) {
const originalBuildGlobs = CodeFileLoader.prototype._buildGlobs
CodeFileLoader.prototype._buildGlobs = function patchedBuildGlobs(glob, options) {
const globs = originalBuildGlobs.call(this, glob, options)
return [...globs, ...CODE_FILE_LOADER_DEFAULT_IGNORES]
}
CodeFileLoader.prototype[CODE_FILE_LOADER_PATCH_FLAG] = true
}
const blocksMap = /* @__PURE__ */ new Map()
let onDiskConfig
let onDiskConfigLoaded = false
const RELEVANT_KEYWORDS = ['gql', 'graphql', 'GraphQL']
/** Регулярний вираз на рівні модуля для e18e/prefer-static-regex. */
const VUE_OR_SVELTE_FILE_RE = /\.(vue|svelte)$/
/** Сегмент шляху до залежностей: виключаємо з пулу sibling-операцій для unique-operation-name / unique-fragment-name. */
const NODE_MODULES_SEGMENT_RE = /[/\\]node_modules[/\\]/
/**
* Чи шлях джерела документа не всередині `node_modules`.
* @param {{ filePath?: string }} entry запис операції/фрагмента з graphql-eslint
* @returns {boolean} `true`, якщо `filePath` заданий і не містить сегмента `node_modules`
*/
function documentEntryOutsideNodeModules(entry) {
return typeof entry?.filePath === 'string' && !NODE_MODULES_SEGMENT_RE.test(entry.filePath)
}
/**
* Обгортка siblingOperations: graphql-config може підтягувати `documents` із `node_modules`, тоді
* правила з `requiresSiblings` бачать дублікати імен між застосунком і залежністю. Фільтр лишає
* лише записи зі шляхів поза `node_modules` (кеш плагіна не змінюється).
* @param {object} siblings оригінальний `siblingOperations` з graphql-eslint parser services
* @returns {object} той самий API з відфільтрованими `getOperation` / `getFragment` / списками
*/
function siblingOperationsWithoutNodeModules(siblings) {
if (!siblings?.available) {
return siblings
}
return {
...siblings,
getFragment: name => siblings.getFragment(name).filter(entry => documentEntryOutsideNodeModules(entry)),
getFragments: () => siblings.getFragments().filter(entry => documentEntryOutsideNodeModules(entry)),
getFragmentByType: typeName =>
siblings.getFragmentByType(typeName).filter(entry => documentEntryOutsideNodeModules(entry)),
getOperation: name => siblings.getOperation(name).filter(entry => documentEntryOutsideNodeModules(entry)),
getOperations: () => siblings.getOperations().filter(entry => documentEntryOutsideNodeModules(entry)),
getOperationByType: type =>
siblings.getOperationByType(type).filter(entry => documentEntryOutsideNodeModules(entry))
}
}
/**
* Шлях для graphql-config: для віртуальних блоків Vue — фізичний .vue, інакше той самий filePath.
* @param {string} filePath шлях файлу, який обробляє processor
* @returns {string} шлях для loadOnDiskGraphQLConfig та getProjectForFile
*/
export function graphQLConfigPathAnchor(filePath = '') {
if (filePath.includes('.vue/')) {
return filePath.slice(0, filePath.indexOf('.vue/') + '.vue'.length)
}
return filePath
}
/** Processor як у graphql-eslint, зі стабільним anchor для graphql-config. */
export const graphqlCodefileProcessor = {
meta: {
name: '@nitra/graphql-codefile-processor',
version
},
supportsAutofix: true,
preprocess(code, filePath) {
if (env.ESLINT_USE_FLAT_CONFIG !== 'false' && filePath.endsWith('.vue')) {
throw new Error(
'Processing of `.vue` files is no longer supported, follow the new official vue example for ESLint flat config https://github.com/graphql-hive/graphql-eslint/tree/master/examples/vue-code-file'
)
}
if (!onDiskConfigLoaded) {
onDiskConfig = loadOnDiskGraphQLConfig(graphQLConfigPathAnchor(filePath))
onDiskConfigLoaded = true
}
let keywords = RELEVANT_KEYWORDS
const anchor = graphQLConfigPathAnchor(filePath)
const pluckConfig = onDiskConfig?.getProjectForFile(anchor)?.extensions?.pluckConfig
if (pluckConfig) {
const { modules = [], globalGqlIdentifierName = ['gql', 'graphql'], gqlMagicComment = 'GraphQL' } = pluckConfig
const mods = modules.map(({ identifier }) => identifier).filter(v => !!v)
const result = [...mods, ...asArray(globalGqlIdentifierName), gqlMagicComment]
keywords = [...new Set(result)]
}
if (keywords.every(keyword => !code.includes(keyword))) {
return [code]
}
try {
const sources = gqlPluckFromCodeStringSync(filePath, code, {
skipIndent: true,
...pluckConfig
})
const blocks = sources.map(item => ({
filename: 'document.graphql',
text: item.body,
lineOffset: item.locationOffset.line - 1,
offset: item.locationOffset.index + 1
}))
blocksMap.set(filePath, blocks)
return [...blocks, code]
} catch (error) {
if (error instanceof Error) {
error.message = `[graphql-eslint] Error while preprocessing "${relative(CWD, filePath)}" file
${error.message}`
}
console.error(error)
return [code]
}
},
postprocess(messages, filePath) {
const blocks = blocksMap.get(filePath) || []
for (const [i, block] of blocks.entries()) {
const { lineOffset, offset } = block
for (const message of messages[i] || []) {
const isVueOrSvelte = VUE_OR_SVELTE_FILE_RE.test(filePath)
if (isVueOrSvelte) {
delete message.endLine
delete message.endColumn
delete message.fix
delete message.suggestions
Object.assign(message, REPORT_ON_FIRST_CHARACTER)
continue
}
message.line += lineOffset
if (typeof message.endLine === 'number') {
message.endLine += lineOffset
}
if (message.fix) {
message.fix.range[0] += offset
message.fix.range[1] += offset
}
for (const suggestion of message.suggestions || []) {
const [start, end] = suggestion.fix.range
suggestion.fix.range = [start + offset, end + offset]
}
}
}
const result = messages.flat()
return result.toSorted((a, b) => a.line - b.line || a.column - b.column)
}
}
const baseParser = graphqlEslintPlugin.parser
/**
* Парсер graphql-eslint: той самий anchor для filePath, щоб loadGraphQLConfig знаходив проєкт для віртуальних document.graphql з-під Vue.
* @param {string} code вміст GraphQL-документа
* @param {import('eslint').Linter.ParserOptions} options опції ESLint (filePath тощо)
* @returns {ReturnType<typeof baseParser.parseForESLint>} AST і parserServices для graphql-eslint
*/
export function graphqlParseForESLintWithAnchor(code, options = {}) {
const filePath = options.filePath || ''
const anchoredFilePath = graphQLConfigPathAnchor(filePath)
const parsed = baseParser.parseForESLint(code, {
...options,
filePath: anchoredFilePath
})
const sibs = parsed.services?.siblingOperations
if (sibs) {
parsed.services.siblingOperations = siblingOperationsWithoutNodeModules(sibs)
}
return parsed
}
/** Парсер для flat config (обгортка над graphql-eslint). */
export const graphqlParserWithConfigAnchor = {
meta: baseParser.meta,
parseForESLint: graphqlParseForESLintWithAnchor
}