فهرست منبع

fix: insert new line after inserting title, abstract or keywords (#29882)

GitOrigin-RevId: d8d79e95d9eb544adaff8850630df996461bacb9
Borja 8 ماه پیش
والد
کامیت
2c00a7a3a4

+ 11 - 0
libraries/eslint-plugin/index.js

@@ -0,0 +1,11 @@
+module.exports = {
+  rules: {
+    'no-unnecessary-trans': require('./no-unnecessary-trans'),
+    'prefer-kebab-url': require('./prefer-kebab-url'),
+    'should-unescape-trans': require('./should-unescape-trans'),
+    'no-generated-editor-themes': require('./no-generated-editor-themes'),
+    'require-script-runner': require('./require-script-runner'),
+    'require-vi-doMock-valid-path': require('./require-vi-doMock-valid-path'),
+    'require-loading-label': require('./require-loading-label'),
+  },
+}

+ 21 - 0
libraries/eslint-plugin/no-generated-editor-themes.js

@@ -0,0 +1,21 @@
+module.exports = {
+  meta: {
+    type: 'error',
+    docs: {
+      description:
+        'Prohibit CodeMirror themes that are generated in a function',
+    },
+  },
+  create(context) {
+    return {
+      ':matches(ArrowFunctionExpression, FunctionDeclaration, FunctionExpression) CallExpression > MemberExpression[object.name="EditorView"]:matches([property.name="theme"],[property.name="baseTheme"])'(
+        node
+      ) {
+        context.report({
+          node,
+          message: `EditorView.theme and EditorView.baseTheme each add CSS to the page for every instance of the theme. Store the theme in a variable and reuse it instead.`,
+        })
+      },
+    }
+  },
+}

+ 43 - 0
libraries/eslint-plugin/no-unnecessary-trans.js

@@ -0,0 +1,43 @@
+module.exports = {
+  meta: {
+    type: 'problem',
+    fixable: 'code',
+    docs: {
+      description: 'Prohibit Trans with no components or values',
+    },
+  },
+  create(context) {
+    return {
+      'JSXOpeningElement[name.name="Trans"]'(node) {
+        const attributes = new Map(
+          node.attributes.map(attr => [attr.name.name, attr])
+        )
+
+        if (!attributes.has('components')) {
+          if (node.parent.children.length > 0) {
+            context.report({
+              node,
+              message: `Trans components must not have child elements`,
+            })
+          } else if (attributes.has('values')) {
+            context.report({
+              node,
+              message: `Use t('…') when there are no components`,
+            })
+          } else {
+            context.report({
+              node,
+              message: `Use t('…') when there are no components`,
+              fix(fixer) {
+                const i18nKey = attributes.get('i18nKey').value.value
+
+                // Note: Prettier can fix indentation
+                return fixer.replaceText(node.parent, `{t('${i18nKey}')}`)
+              },
+            })
+          }
+        }
+      },
+    }
+  },
+}

+ 17 - 0
libraries/eslint-plugin/package.json

@@ -0,0 +1,17 @@
+{
+  "name": "@overleaf/eslint-plugin",
+  "version": "0.1.0",
+  "author": "Overleaf (https://www.overleaf.com)",
+  "license": "AGPL-3.0-only",
+  "main": "index.js",
+  "dependencies": {
+    "eslint": "^8.51.0",
+    "lodash": "^4.17.21"
+  },
+  "devDependencies": {
+    "@typescript-eslint/parser": "^8.30.1"
+  },
+  "scripts": {
+    "test": "node rules.test.js"
+  }
+}

+ 83 - 0
libraries/eslint-plugin/prefer-kebab-url-ignore.js

@@ -0,0 +1,83 @@
+// URL parts should be kebab-case, but we didn't have this rule in the past.
+// The ESLint rule `prefer-kebab-url` will ignore these "legacy" URL parts.
+
+const ignoreWords = {
+  snake: new Set([
+    'clear_saml_data',
+    'confirm_link',
+    'confirm_university_domain',
+    'create_recurly_account',
+    'current_history_content',
+    'current_user',
+    'default_email',
+    'disable_managed_users',
+    'doc_snapshot',
+    'enable_history_ranges_support',
+    'features_override',
+    'generate_password_reset_url',
+    'get_assignment',
+    'get_clone',
+    'health_check',
+    'institutional_emails',
+    'latest_template',
+    'link_after_saml_response',
+    'linked_file',
+    'metrics_segmentation',
+    'new_users',
+    'no_autostart_post_gateway',
+    'personal_info',
+    'planned_maintenance',
+    'refresh_features',
+    'register_admin',
+    'register_ldap_admin',
+    'register_saml_admin',
+    'restore_file',
+    'revert_file',
+    'saved_vers',
+    'send_test_email',
+    'session_maintenance',
+    'set_in_session',
+    'sign_in_to_link',
+    'split_test',
+    'sso_configuration_test',
+    'sso_email',
+    'sso_enrollment',
+    'track_changes',
+    'update_admin',
+    'user_details',
+  ]),
+  camel: new Set([
+    'addWorkflowScope',
+    'aiErrorAssistant',
+    'beginAuth',
+    'brandVariationId',
+    'closeEditor',
+    'completeRegistration',
+    'deactivateOldProjects',
+    'deletedSubscription',
+    'disconnectAllUsers',
+    'editingSession',
+    'emailSubscription',
+    'enableManagedUsers',
+    'externalCollaboration',
+    'flushProjectToTpds',
+    'indexAll',
+    'offboardManagedUser',
+    'openEditor',
+    'perfTest',
+    'pollDropboxForUser',
+    'resendInvite',
+    'resendManagedUserInvite',
+    'salesContactForm',
+    'showSupport',
+  ]),
+  other: new Set([
+    'Project',
+    'disableSSO',
+    'enableSSO',
+    'resendSSOLinkInvite',
+    'usersCSV',
+  ]),
+}
+
+module.exports = { ignoreWords }

+ 91 - 0
libraries/eslint-plugin/prefer-kebab-url.js

@@ -0,0 +1,91 @@
+const _ = require('lodash')
+const { ignoreWords } = require('./prefer-kebab-url-ignore')
+
+const removeTextBetweenBrackets = text => {
+  while (text.includes('[') || text.includes('(')) {
+    text = text.replaceAll(/\[[^[\]]*]/g, '')
+    text = text.replaceAll(/\([^()]*\)/g, '')
+  }
+  return text
+}
+
+const shouldIgnoreWord = str =>
+  str.includes(':') ||
+  str.includes('(') ||
+  str === '*' ||
+  str.match(/^[a-z0-9.]+$/) ||
+  ignoreWords.snake.has(str) ||
+  ignoreWords.camel.has(str) ||
+  ignoreWords.other.has(str)
+
+const getSuggestion = routePath => {
+  if (typeof routePath === 'string') {
+    const kebabed = routePath
+      .split('/')
+      .map(word => (shouldIgnoreWord(word) ? word : _.kebabCase(word)))
+      .join('/')
+    return kebabed === routePath ? null : `'${kebabed}'`
+  }
+
+  if (routePath instanceof RegExp) {
+    const words = removeTextBetweenBrackets(routePath.source).match(/[\w-]+/g)
+    if (!words) return routePath
+
+    let newSource = routePath.source
+    for (const word of words) {
+      if (!shouldIgnoreWord(word)) {
+        newSource = newSource.replaceAll(
+          new RegExp(`\\b${word}\\b`, 'g'),
+          _.kebabCase(word)
+        )
+      }
+    }
+
+    const kebabed = new RegExp(newSource, routePath.flags)
+    return kebabed.source.toString() === routePath.source.toString()
+      ? null
+      : kebabed
+  }
+}
+
+module.exports = {
+  meta: {
+    type: 'problem',
+    fixable: 'code',
+    hasSuggestions: true,
+    docs: {
+      description: 'Enforce using kebab-case for URL paths',
+    },
+  },
+  create: context => ({
+    CallExpression(node) {
+      if (
+        node.callee.type === 'MemberExpression' &&
+        node.arguments[0]?.type === 'Literal' &&
+        [/app/i, /router/i].some(callee =>
+          typeof callee === 'string'
+            ? node.callee.object.name === callee
+            : callee.test(node.callee.object.name)
+        ) &&
+        ['get', 'post', 'put', 'delete'].includes(node.callee.property.name)
+      ) {
+        const routePath = node.arguments[0].value
+
+        const suggestion = getSuggestion(routePath)
+
+        if (suggestion) {
+          context.report({
+            node: node.arguments[0],
+            message: 'Route path should be in kebab-case.',
+            suggest: [
+              {
+                desc: `Change to kebab-case: ${suggestion}`,
+                fix: fixer => fixer.replaceText(node.arguments[0], suggestion),
+              },
+            ],
+          })
+        }
+      }
+    },
+  }),
+}

+ 49 - 0
libraries/eslint-plugin/require-loading-label.js

@@ -0,0 +1,49 @@
+module.exports = {
+  meta: {
+    type: 'problem',
+    fixable: null,
+    docs: {
+      description: 'Require loadingLabel prop when isLoading is specified on OLButton',
+    },
+    schema: [],
+  },
+  create(context) {
+    return {
+      'JSXOpeningElement[name.name="OLButton"]'(node) {
+        const attributes = new Map(
+          node.attributes.map(attr => [
+            attr.name?.name,
+            attr
+          ])
+        )
+
+        const isLoadingAttr = attributes.get('isLoading')
+        const loadingLabelAttr = attributes.get('loadingLabel')
+
+        if (isLoadingAttr && !loadingLabelAttr) {
+          const isLoadingValue = isLoadingAttr.value
+
+          if (
+            !isLoadingValue ||
+            (isLoadingValue.type === 'JSXExpressionContainer' &&
+             isLoadingValue.expression.type === 'Literal' &&
+             isLoadingValue.expression.value === true)
+          ) {
+            context.report({
+              node: isLoadingAttr,
+              message: 'Button with isLoading prop must also specify loadingLabel',
+            })
+          } else if (
+            isLoadingValue.type === 'JSXExpressionContainer' &&
+            isLoadingValue.expression.type !== 'Literal'
+          ) {
+            context.report({
+              node: isLoadingAttr,
+              message: 'Button with isLoading prop must also specify loadingLabel',
+            })
+          }
+        }
+      },
+    }
+  },
+}

+ 28 - 0
libraries/eslint-plugin/require-script-runner.js

@@ -0,0 +1,28 @@
+module.exports = {
+  meta: {
+    type: 'suggestion',
+    docs: {
+      description: 'Require Script Runner for scripts',
+    },
+  },
+  create(context) {
+    let hasImport = false
+
+    return {
+      ImportDeclaration(node) {
+        if (node.source.value.endsWith('lib/ScriptRunner.mjs')) {
+          hasImport = true
+        }
+      },
+      'Program:exit'() {
+        if (!hasImport) {
+          context.report({
+            loc: { line: 1, column: 0 },
+            message:
+              'Please use Script Runner for scripts. Refer to the developer manual (https://manual.dev-overleaf.com/development/code/web_scripts/#monitor-script-execution-and-usage-with-script-runner) for more information.',
+          })
+        }
+      },
+    }
+  },
+}

+ 121 - 0
libraries/eslint-plugin/require-vi-doMock-valid-path.js

@@ -0,0 +1,121 @@
+const path = require('node:path');
+const fs = require('node:fs');
+
+module.exports = {
+  meta: {
+    type: 'problem',
+    docs: {
+      description: 'Ensure vi.doMock first argument is a resolvable path.',
+      category: 'Best Practices',
+      recommended: false,
+      url: '',
+    },
+    fixable: 'code',
+    hasSuggestions: true,
+    schema: [],
+    messages: {
+      unresolvablePath: 'The path "{{pathValue}}" in vi.doMock() cannot be resolved relative to the current file.',
+      notAStringLiteral: 'The first argument of vi.doMock() must be (or resolve to) a string literal representing a path.',
+      noArguments: 'vi.doMock() called with no arguments.',
+    },
+  },
+  create(context) {
+    const currentFilePath = context.getFilename();
+    // ESLint can sometimes pass <text> or <input> for snippets not in a file
+    if (currentFilePath === '<text>' || currentFilePath === '<input>') {
+      return {}
+    }
+    const currentDirectory = path.dirname(currentFilePath);
+
+    function canResolve(modulePath) {
+      try {
+        require.resolve(path.resolve(currentDirectory, modulePath));
+        return true;
+      } catch (e) {
+        const absolutePath = path.resolve(currentDirectory, modulePath);
+        const extensions = ['', '.js', '.mjs', '.ts', '.jsx', '.tsx', '.json', '.node', '/index.js', '/index.ts']; // Add common extensions
+        for (const ext of extensions) {
+          if (fs.existsSync(absolutePath + ext)) {
+            return true
+          }
+        }
+        return false
+      }
+    }
+
+    return {
+      CallExpression(node) {
+        if (
+          node.callee.type === 'MemberExpression' &&
+          node.callee.object.type === 'Identifier' &&
+          node.callee.object.name === 'vi' &&
+          node.callee.property.type === 'Identifier' &&
+          node.callee.property.name === 'doMock'
+        ) {
+          if (node.arguments.length === 0) {
+            context.report({
+              node,
+              messageId: 'noArguments',
+            })
+            return
+          }
+
+          const firstArg = node.arguments[0]
+          let pathValue = firstArg.value
+
+          if (firstArg.type !== 'Literal' || typeof firstArg.value !== 'string') {
+            if (firstArg.type === 'Identifier') {
+              const variable = context.getScope().variables.find(v => v.name === firstArg.name);
+              if (
+                variable &&
+                variable.defs.length > 0 &&
+                variable.defs[0].node.init &&
+                variable.defs[0].node.init.type === 'Literal' &&
+                typeof variable.defs[0].node.init.value === 'string'
+              ) {
+                pathValue = variable.defs[0].node.init.value
+                if (canResolve(pathValue)) {
+                  return
+                }
+                // If the first argument was a variable that didn't resolve then we can't auto-fix it
+              }
+            }
+              context.report({
+                node: firstArg,
+                messageId: 'notAStringLiteral',
+              })
+              return
+
+          }
+
+
+          if (!pathValue.startsWith('.')) {
+            return
+          }
+
+          if (!canResolve(pathValue)) {
+            const mjsPath = pathValue.replace('.js', '.mjs')
+            const additionalReportOptions = {}
+            if (canResolve(mjsPath)) {
+              additionalReportOptions.fix = (fixer) => fixer.replaceText(firstArg, `'${mjsPath}'`)
+              additionalReportOptions.suggest = [
+                {
+                  desc: `Replace with "${pathValue.replace('.js', '.mjs')}"`,
+                  fix: (fixer) => fixer.replaceText(firstArg, `'${mjsPath}'`),
+                }
+              ]
+            }
+            context.report({
+              node: firstArg,
+              messageId: 'unresolvablePath',
+              data: {
+                pathValue,
+              },
+              ...additionalReportOptions
+            })
+          }
+        }
+      },
+    }
+  },
+};

+ 161 - 0
libraries/eslint-plugin/rules.test.js

@@ -0,0 +1,161 @@
+const { RuleTester } = require('eslint')
+const preferKebabUrl = require('./prefer-kebab-url')
+const noUnnecessaryTrans = require('./no-unnecessary-trans')
+const shouldUnescapeTrans = require('./should-unescape-trans')
+const noGeneratedEditorThemes = require('./no-generated-editor-themes')
+const viDoMockValidPath = require('./require-vi-doMock-valid-path')
+
+const ruleTester = new RuleTester({
+  parser: require.resolve('@typescript-eslint/parser'),
+  parserOptions: {
+    ecmaVersion: 'latest',
+    ecmaFeatures: { jsx: true },
+  },
+})
+
+ruleTester.run('prefer-kebab-url', preferKebabUrl, {
+  valid: [
+    { code: `app.get('/foo-bar')` },
+    { code: `app.get('/foo-bar/:id')` },
+    { code: `router.post('/foo-bar')` },
+    { code: `router.get('/foo-bar/:id/:name/:age')` },
+    { code: `webRouter.get('/foo-bar/:user_id/(ProjectName)/get-info')` },
+    { code: `webApp.post('/foo-bar/:user_id/(ProjectName)/get-info')` },
+    {
+      code: `router.get(/^\\/download\\/project\\/([^/]*)\\/output\\/output\\.pdf$/)`,
+    },
+    {
+      code: `webRouter.get(/^\\/project\\/([^/]*)\\/user\\/([0-9a-f]+)\\/build\\/([0-9a-f-]+)\\/output\\/(.*)$/)`,
+    },
+  ],
+  invalid: [
+    {
+      code: `app.get('/fooBar')`,
+      errors: [{ message: 'Route path should be in kebab-case.' }],
+    },
+    {
+      code: `app.get('/fooBar/:id')`,
+      errors: [{ message: 'Route path should be in kebab-case.' }],
+    },
+    {
+      code: `webRouter.get('/foo_bar/:id/FooBar/:name/fooBar')`,
+      errors: [{ message: 'Route path should be in kebab-case.' }],
+    },
+    {
+      code: `router.get(/^\\/downLoad\\/pro-ject\\/([^/]*)\\/OutPut\\/out-put\\.pdf$/)`,
+      errors: [{ message: 'Route path should be in kebab-case.' }],
+    },
+  ],
+})
+
+ruleTester.run('no-unnecessary-trans', noUnnecessaryTrans, {
+  valid: [
+    { code: `<Trans i18nKey="test" components={{ strong: <strong/> }}/>` },
+  ],
+  invalid: [
+    {
+      code: `<Trans i18nKey="test" values={{ test: 'foo '}}/>`,
+      errors: [{ message: `Use t('…') when there are no components` }],
+    },
+    {
+      code: `<Trans i18nKey="test" />`,
+      errors: [{ message: `Use t('…') when there are no components` }],
+      output: `{t('test')}`,
+    },
+  ],
+})
+
+ruleTester.run('should-unescape-trans', shouldUnescapeTrans, {
+  valid: [
+    {
+      code: `<Trans i18nKey="test" components={{ strong: <strong/> }}/>`,
+    },
+    {
+      code: `<Trans i18nKey="test" values={{ foo: 'bar' }} components={{ strong: <strong/> }} shouldUnescape tOptions={{ interpolation: { escapeValue: true } }}/>`,
+    },
+  ],
+  invalid: [
+    {
+      code: `<Trans i18nKey="test" values={{ foo: 'bar' }} components={{ strong: <strong/> }} />`,
+      errors: [{ message: 'Trans with values must have shouldUnescape' }],
+      output: `<Trans i18nKey="test" values={{ foo: 'bar' }}\nshouldUnescape components={{ strong: <strong/> }} />`,
+    },
+    {
+      code: `<Trans i18nKey="test" values={{ foo: 'bar' }} components={{ strong: <strong/> }} shouldUnescape />`,
+      errors: [
+        {
+          message:
+            'Trans with shouldUnescape must have tOptions.interpolation.escapeValue',
+        },
+      ],
+      output: `<Trans i18nKey="test" values={{ foo: 'bar' }} components={{ strong: <strong/> }} shouldUnescape\ntOptions={{ interpolation: { escapeValue: true } }} />`,
+    },
+  ],
+})
+
+const noGeneratedEditorThemesError =
+  'EditorView.theme and EditorView.baseTheme each add CSS to the page for every instance of the theme. Store the theme in a variable and reuse it instead.'
+ruleTester.run('no-generated-editor-themes', noGeneratedEditorThemes, {
+  valid: [
+    {
+      code: `EditorView.theme({ '.cm-editor': { color: 'black' } })`,
+    },
+    {
+      code: `const theme = EditorView.theme({ '.cm-editor': { color: 'black' } })`,
+    },
+  ],
+  invalid: [
+    {
+      code: `function createTheme() { return EditorView.theme({ '.cm-editor': { color: 'black' } }) }`,
+      errors: [
+        {
+          message: noGeneratedEditorThemesError,
+        },
+      ],
+    },
+    {
+      code: `() => EditorView.theme({ '.cm-editor': { color: 'black' } })`,
+      errors: [
+        {
+          message: noGeneratedEditorThemesError,
+        },
+      ],
+    },
+    {
+      code: `class Foo { createTheme() { return EditorView.theme({ '.cm-editor': { color: 'black' } }) } }`,
+      errors: [
+        {
+          message: noGeneratedEditorThemesError,
+        },
+      ],
+    },
+  ],
+})
+
+ruleTester.run('domock-require-valid-path', viDoMockValidPath, {
+  valid: [
+    {
+      code: 'vi.doMock("./require-vi-doMock-valid-path.js")',
+      filename: __filename
+    },
+    {
+      code: 'const filename = "./require-vi-doMock-valid-path.js"; vi.doMock(filename);',
+      filename: __filename
+    }
+  ],
+  invalid: [{
+    code: "vi.doMock('./require-vi-doMock-valid-path2')",
+    filename: __filename,
+    errors: [
+      {
+        message: 'The path "./require-vi-doMock-valid-path2" in vi.doMock() cannot be resolved relative to the current file.'}
+    ]
+  }, {
+    code: 'const filename = "./require-vi-doMock-valid-path2.js"; vi.doMock(filename);',
+    filename: __filename,
+    errors: [
+      {
+        message: 'The first argument of vi.doMock() must be (or resolve to) a string literal representing a path.'}
+    ]
+  }]
+})

+ 60 - 0
libraries/eslint-plugin/should-unescape-trans.js

@@ -0,0 +1,60 @@
+module.exports = {
+  meta: {
+    type: 'problem',
+    fixable: 'code',
+    docs: {
+      description: 'Ensure that Trans with values has shouldUnescape',
+    },
+  },
+  create(context) {
+    return {
+      'JSXOpeningElement[name.name="Trans"]'(node) {
+        const attributes = new Map(
+          node.attributes.map(attr => [attr.name.name, attr])
+        )
+
+        if (attributes.has('values') && !attributes.has('shouldUnescape')) {
+          context.report({
+            node,
+            message: 'Trans with values must have shouldUnescape',
+            fix(fixer) {
+              return fixer.insertTextAfter(
+                attributes.get('values'),
+                '\nshouldUnescape' // Note: Prettier can fix indentation
+              )
+            },
+          })
+        }
+
+        if (attributes.has('values') && attributes.has('shouldUnescape')) {
+          const tOptions = attributes.get('tOptions')
+          if (!tOptions) {
+            context.report({
+              node,
+              message:
+                'Trans with shouldUnescape must have tOptions.interpolation.escapeValue',
+              fix(fixer) {
+                return fixer.insertTextAfter(
+                  attributes.get('shouldUnescape'),
+                  '\ntOptions={{ interpolation: { escapeValue: true } }}' // Note: Prettier can fix indentation
+                )
+              },
+            })
+          } else {
+            const property = tOptions.value.expression.properties
+              .find(p => p.key.name === 'interpolation')
+              ?.value.properties.find(p => p.key.name === 'escapeValue')
+
+            if (property?.value.value !== true) {
+              context.report({
+                node,
+                message:
+                  'Trans with shouldUnescape must have tOptions.interpolation.escapeValue set to true',
+              })
+            }
+          }
+        }
+      },
+    }
+  },
+}

+ 6 - 0
libraries/eslint-plugin/tsconfig.json

@@ -0,0 +1,6 @@
+{
+  "extends": "../../tsconfig.backend.json",
+  "include": [
+    "**/*.js",
+  ]
+}