From 2c00a7a3a4bb013786e5c943fd004ea2ed973d43 Mon Sep 17 00:00:00 2001 From: Borja <158476064+borja-writefull@users.noreply.github.com> Date: Tue, 25 Nov 2025 10:22:52 +0100 Subject: [PATCH] fix: insert new line after inserting title, abstract or keywords (#29882) GitOrigin-RevId: d8d79e95d9eb544adaff8850630df996461bacb9 --- libraries/eslint-plugin/index.js | 11 ++ .../no-generated-editor-themes.js | 21 +++ .../eslint-plugin/no-unnecessary-trans.js | 43 +++++ libraries/eslint-plugin/package.json | 17 ++ .../eslint-plugin/prefer-kebab-url-ignore.js | 83 +++++++++ libraries/eslint-plugin/prefer-kebab-url.js | 91 ++++++++++ .../eslint-plugin/require-loading-label.js | 49 ++++++ .../eslint-plugin/require-script-runner.js | 28 +++ .../require-vi-doMock-valid-path.js | 121 +++++++++++++ libraries/eslint-plugin/rules.test.js | 161 ++++++++++++++++++ .../eslint-plugin/should-unescape-trans.js | 60 +++++++ libraries/eslint-plugin/tsconfig.json | 6 + 12 files changed, 691 insertions(+) create mode 100644 libraries/eslint-plugin/index.js create mode 100644 libraries/eslint-plugin/no-generated-editor-themes.js create mode 100644 libraries/eslint-plugin/no-unnecessary-trans.js create mode 100644 libraries/eslint-plugin/package.json create mode 100644 libraries/eslint-plugin/prefer-kebab-url-ignore.js create mode 100644 libraries/eslint-plugin/prefer-kebab-url.js create mode 100644 libraries/eslint-plugin/require-loading-label.js create mode 100644 libraries/eslint-plugin/require-script-runner.js create mode 100644 libraries/eslint-plugin/require-vi-doMock-valid-path.js create mode 100644 libraries/eslint-plugin/rules.test.js create mode 100644 libraries/eslint-plugin/should-unescape-trans.js create mode 100644 libraries/eslint-plugin/tsconfig.json diff --git a/libraries/eslint-plugin/index.js b/libraries/eslint-plugin/index.js new file mode 100644 index 0000000000..10598abe45 --- /dev/null +++ b/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'), + }, +} diff --git a/libraries/eslint-plugin/no-generated-editor-themes.js b/libraries/eslint-plugin/no-generated-editor-themes.js new file mode 100644 index 0000000000..898406c860 --- /dev/null +++ b/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.`, + }) + }, + } + }, +} diff --git a/libraries/eslint-plugin/no-unnecessary-trans.js b/libraries/eslint-plugin/no-unnecessary-trans.js new file mode 100644 index 0000000000..fc7b845e3f --- /dev/null +++ b/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}')}`) + }, + }) + } + } + }, + } + }, +} diff --git a/libraries/eslint-plugin/package.json b/libraries/eslint-plugin/package.json new file mode 100644 index 0000000000..87d224ead9 --- /dev/null +++ b/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" + } +} diff --git a/libraries/eslint-plugin/prefer-kebab-url-ignore.js b/libraries/eslint-plugin/prefer-kebab-url-ignore.js new file mode 100644 index 0000000000..ccb122566f --- /dev/null +++ b/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 } diff --git a/libraries/eslint-plugin/prefer-kebab-url.js b/libraries/eslint-plugin/prefer-kebab-url.js new file mode 100644 index 0000000000..9c7b834dca --- /dev/null +++ b/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), + }, + ], + }) + } + } + }, + }), +} diff --git a/libraries/eslint-plugin/require-loading-label.js b/libraries/eslint-plugin/require-loading-label.js new file mode 100644 index 0000000000..cd3f9b7b0a --- /dev/null +++ b/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', + }) + } + } + }, + } + }, +} diff --git a/libraries/eslint-plugin/require-script-runner.js b/libraries/eslint-plugin/require-script-runner.js new file mode 100644 index 0000000000..31b32b96cb --- /dev/null +++ b/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.', + }) + } + }, + } + }, +} diff --git a/libraries/eslint-plugin/require-vi-doMock-valid-path.js b/libraries/eslint-plugin/require-vi-doMock-valid-path.js new file mode 100644 index 0000000000..17e3f8c765 --- /dev/null +++ b/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 or for snippets not in a file + if (currentFilePath === '' || currentFilePath === '') { + 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 + }) + } + } + }, + } + }, +}; diff --git a/libraries/eslint-plugin/rules.test.js b/libraries/eslint-plugin/rules.test.js new file mode 100644 index 0000000000..d4f1052415 --- /dev/null +++ b/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: ` }}/>` }, + ], + invalid: [ + { + code: ``, + errors: [{ message: `Use t('…') when there are no components` }], + }, + { + code: ``, + errors: [{ message: `Use t('…') when there are no components` }], + output: `{t('test')}`, + }, + ], +}) + +ruleTester.run('should-unescape-trans', shouldUnescapeTrans, { + valid: [ + { + code: ` }}/>`, + }, + { + code: ` }} shouldUnescape tOptions={{ interpolation: { escapeValue: true } }}/>`, + }, + ], + invalid: [ + { + code: ` }} />`, + errors: [{ message: 'Trans with values must have shouldUnescape' }], + output: ` }} />`, + }, + { + code: ` }} shouldUnescape />`, + errors: [ + { + message: + 'Trans with shouldUnescape must have tOptions.interpolation.escapeValue', + }, + ], + output: ` }} 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.'} + ] + }] +}) diff --git a/libraries/eslint-plugin/should-unescape-trans.js b/libraries/eslint-plugin/should-unescape-trans.js new file mode 100644 index 0000000000..5701600c24 --- /dev/null +++ b/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', + }) + } + } + } + }, + } + }, +} diff --git a/libraries/eslint-plugin/tsconfig.json b/libraries/eslint-plugin/tsconfig.json new file mode 100644 index 0000000000..4ae1be95bf --- /dev/null +++ b/libraries/eslint-plugin/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.backend.json", + "include": [ + "**/*.js", + ] +}