Files
overleaf-cep/services/web/scripts/esm-check-migration.mjs
T
Antoine ClausseandClaude Opus 4.7 9dde7d3d71 [web] Add ESLint rules for locale JSON (#34221)
* Remove double spaces

* Enforce curly apostrophes

* Enforce French typographic spaces in fr.json

Add vitest test enforcing French spacing rules per
https://typographisme.net/post/Les-espaces-typographiques-et-le-web:
  - NNBSP (U+202F) before ? ! ;
  - NBSP (U+00A0) before :
  - NBSP (U+00A0) around guillemets « »

Fix 38 fr.json values that were using NBSP (or no space) before ? ! ;.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Extend French typography test to numbers and percent signs

Add rules per https://typographisme.net/post/Les-espaces-typographiques-et-le-web:
  - NNBSP (U+202F) between digit groups (thousands separator)
  - NBSP (U+00A0) between a number and a following word/unit
  - NBSP (U+00A0) before %

Fix 9 fr.json values where a regular space was used instead of NBSP between
a number and a following word (e.g. "1 résultat", "10 jetons", "7 jours").

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Use point médian for inclusive writing in fr.json

Replace parenthetical gender/plural markers like "sûr(e)", "abonné(e)",
"licence(s)" with the point médian "·" (e.g. "sûr·e", "abonné·e",
"licence·s") per modern French écriture inclusive.

Add a vitest rule rejecting "word(suffix)" patterns where suffix is 1-3
letters and there is no space before the opening parenthesis.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Simplify inclusive-writing check to "(e)" substring match

The previous regex (matchAll without the /g flag) would have thrown at
runtime. Drop matchAll entirely and use includes('(e)').

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Add point médian to participles in fr.json

Apply inclusive writing (·e) to past participles agreeing with the
addressed user ("affilié·e", "invité·e", "connecté·e", "abonné·e", etc.).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Use inclusive forms in fr.json

* Change "de Overleaf" to "d’Overleaf"

* Fix tests

* Check for consecutive spaces, not just double spaces

* Update comment about guillemets

* Add ESLint rules for locale JSON conventions

Add three rules to @overleaf/eslint-plugin that run against locale JSON
files via @eslint/json:

- no-consecutive-spaces-in-locales
- no-straight-apostrophes-in-locales
- french-typography-in-locales

Wire them up in services/web/eslint.config.mjs against `locales/*.json`
(and `locales/fr.json` for the French-typography rule).

* Add `@eslint/json` so JSON files can be parsed

* Scope JS/TS-specific config to JS/TS files only so that built-in rules aren't applied to JSON files

* Disable `no-irregular-whitespace` in french-typography-in-locales.js

* Make the rules fixable

* Improve regexes: user `\s` instead of custom matchers

* Add sorted-keys-in-locales ESLint rule

Mirrors scripts/translations/sort.js as a fixable ESLint rule:
require top-level keys in locale JSON files to be sorted alphabetically.
The autofix re-serializes the file with sorted keys.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Add no-unused-locale-keys ESLint rule

Mirrors scripts/translations/cleanupUnusedLocales.js as a fixable ESLint
rule applied only to en.json. Shells out to `find` + `cat` once per
process (cached) to grep all candidate source files for key references,
then reports any en.json key that isn't found. The autofix re-serializes
en.json without the unused keys.

Hardcoded ALWAYS_USED set covers keys built at runtime (month names,
third-party-datastore notifications) that the regex scan can't see.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Add locale-variables-match-en ESLint rule

Mirrors scripts/translations/checkVariables.js as an ESLint rule applied
to non-en locale JSON files. Reads en.json (cached) and reports any
member whose `__name__` placeholders differ from the base.

GLOBALS (e.g. __appName__) are allowed as extras; IGNORE_NESTING_FOR
preserves the script's per-key exceptions.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Replace some double linebreak (@overleaf/no-consecutive-spaces-in-locales)

* Add no-orphan-locale-keys ESLint rule

Detect translation keys in non-en locale files that aren't present in
en.json. Mirrors the `--sync-non-en` flag of cleanupUnusedLocales.js.
The autofix re-serializes the locale file without the orphan keys,
sorted alphabetically.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Add tests for the locale JSON ESLint rules

Cover the four single-file rules:
  - no-consecutive-spaces-in-locales
  - no-straight-apostrophes-in-locales
  - sorted-keys-in-locales
  - french-typography-in-locales (including the inclusive-writing
    suggestion for "(e)")

The three cross-file rules (no-unused-locale-keys, no-orphan-locale-keys,
locale-variables-match-en) read from disk and aren't covered here; they
need integration tests via fixtures.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Drop bin/lint_locales — checks are now covered by ESLint rules

The four checks in bin/lint_locales are duplicated by the new
@overleaf/eslint-plugin rules:

  - sort.js --check                  → sorted-keys-in-locales
  - cleanupUnusedLocales.js --check  → no-unused-locale-keys
                                     + no-orphan-locale-keys
  - checkVariables.js                → locale-variables-match-en
  - single-quote grep                → no-straight-apostrophes-in-locales

`yarn lint` runs the ESLint rules against locales/*.json, so the
lint_locales script and Makefile target are redundant.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Remove Locales.test.mjs

* Delete scripts replaced by @overleaf/eslint-plugin rules

Remove the three locale scripts now superseded by ESLint rules:

  - scripts/translations/sort.js                   → sorted-keys-in-locales
  - scripts/translations/cleanupUnusedLocales.js   → no-unused-locale-keys
                                                   + no-orphan-locale-keys
  - scripts/translations/checkVariables.js         → locale-variables-match-en

Also drop the matching Makefile targets (sort_locales,
cleanup_unused_locales), update AGENTS.md / copilot-instructions.md to
point at `yarn lint --fix` instead, and prune the deleted entries from
the esm-check-migration allowlist.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Delete redundant scripts/translations/sort-locale-file.js

Single-file sort utility — superseded by `yarn lint --fix path/to/file.json`
via the sorted-keys-in-locales ESLint rule. No callers in repo.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Update comments and messages

* Add test file pattern to ESLint configuration

* Reorder "Backend specific rules" so `no-extraneous-dependencies` doesn't fail on test files

* Make french-typography fixes idempotent and catch edge cases

Apply Copilot's review feedback on PR #34221:

  - autoFix() now uses `\\s*`/`\\s+` instead of `\\s?`/`\\s` so it
    collapses any run of mixed whitespace into the correct NBSP/NNBSP,
    making the fix idempotent and order-independent.
  - The `:` lint regex now captures optional whitespace and reports
    when it isn't NBSP (catches `"Liste :"` with a regular space).
  - The `%` lint regex now matches the missing-space case (`"10%"`)
    via `\\s*` instead of requiring one char between digit and `%`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Remove sort-locale-file.js from pre-commit

* Revert no-unused-locale-keys ESLint rule; Restore lint_locales scripts

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
GitOrigin-RevId: 8351fab074e6e7f77920b70237df1cd1c781e053
2026-06-10 08:06:44 +00:00

301 lines
8.8 KiB
JavaScript

import fs from 'node:fs'
import path from 'node:path'
import minimist from 'minimist'
const APP_CODE_PATH = ['app', 'modules', 'scripts', 'test']
// These have already been converted but don't have a `.mjs` extension
const converted = new Set([
'scripts/ukamf/check-certs.js',
'scripts/ukamf/check-idp-metadata.js',
'scripts/ukamf/metadata-processor.js',
'scripts/ukamf/ukamf-db.js',
'scripts/ukamf/ukamf-entity.js',
'scripts/translations/checkCoverage.js',
'scripts/translations/checkSanitizeOptions.js',
'scripts/translations/checkVariables.js',
'scripts/translations/cleanupUnusedLocales.js',
'scripts/translations/config.js',
'scripts/translations/download.js',
'scripts/translations/insertHTMLFragments.js',
'scripts/translations/replaceLinkFragments.js',
'scripts/translations/sanitize.js',
'scripts/translations/transformLocales.js',
'scripts/translations/translateLocales.js',
'scripts/translations/upload.js',
'scripts/translations/uploadNonEnglish.js',
'scripts/translations/utils.js',
])
// These files are not to be converted (e.g. they use CommonJS features that are not available in ES Modules)
const excluded = new Set([
'modules/server-ce-scripts/scripts/create-user.js', // must be CJS for backwards compatibility
'test/acceptance/config/settings.test.saas.js', // must be CJS for @overleaf/settings module
'test/acceptance/config/settings.test.server-pro.js', // must be CJS for @overleaf/settings module
'app/src/infrastructure/PackageVersions.js', // required by webpack
])
function fileIsESM(file) {
const relativePath = file.replace(process.cwd() + '/', '')
return file.endsWith('.mjs') || converted.has(relativePath)
}
function fileCanBeConvertedToESM(file) {
const relativePath = file.replace(process.cwd() + '/', '')
if (fileIsESM(relativePath)) {
return false
}
return !excluded.has(relativePath)
}
const {
_: args,
files,
help,
json,
} = minimist(process.argv.slice(2), {
boolean: ['files', 'help', 'json'],
alias: {
files: 'f',
help: 'h',
json: 'j',
},
default: {
files: false,
help: false,
json: false,
},
})
const paths = args.length > 0 ? args : APP_CODE_PATH
function usage() {
console.error(`Usage: node check-esm-migration.js [OPTS...] dir1 dir2
node check-esm-migration.js file
Usage with directories
----------------------
When the arguments are a list of directories it prints the status of ES Modules migration within those directories.
When no directory is provided, it checks app/ and modules/, which represent the entire codebase.
With the --files (-f) option, it prints the list of JS files that:
- Are not migrated to ESM
- Are not required by any file that is not migrated yet to ESM (in the entire codebase)
These files should be the most immediate candidates to be migrated.
WARNING: please note that this script only looks up literals in require() statements, so paths
built dynamically (such as those in infrastructure/Modules.js) are not being taken into account.
Usage with a JS file
--------------------
When the argument is a JS file, the script outputs the files that depend on this file that have not been converted
yet to ES Modules.
The files in the list must to be converted to ES Modules before converting the JS file.
Example:
node scrips/check-esm-migration.js --files modules/admin-panel
node scrips/check-esm-migration.js app/src/router.js
Options:
--files Prints the files that are not imported by app code via CommonJS
--json Prints the result in JSON format, including the list of files from --files
--help Prints this help
`)
}
function resolveImportPaths(dir, file) {
const absolutePath = path.resolve(dir, file)
if (fs.existsSync(absolutePath)) {
return absolutePath
} else if (fs.existsSync(absolutePath + '.js')) {
return absolutePath + '.js'
} else if (fs.existsSync(absolutePath + '.mjs')) {
return absolutePath + '.mjs'
} else {
return null
}
}
function collectJsFiles(dir, files = []) {
const items = fs.readdirSync(dir)
items.forEach(item => {
const fullPath = path.join(dir, item)
const stat = fs.statSync(fullPath)
if (stat.isDirectory()) {
const basename = path.basename(fullPath)
// skipping directories from search
if (!['frontend', 'node_modules'].includes(basename)) {
collectJsFiles(fullPath, files)
}
} else if (
stat.isFile() &&
(fullPath.endsWith('.js') || fullPath.endsWith('.mjs'))
) {
files.push(fullPath)
}
})
return files
}
function extractImports(filePath) {
const fileContent = fs.readFileSync(filePath, 'utf-8')
// not 100% compliant (string escaping, etc.) but does the work here
const contentWithoutComments = fileContent.replace(
/\/\/.*|\/\*[\s\S]*?\*\//g,
''
)
const requireRegex = /require\s*\(\s*['"](.+?)['"]\s*\)/g
const dependencies = []
while (true) {
const match = requireRegex.exec(contentWithoutComments)
if (!match) {
break
}
dependencies.push(match[1])
}
// build absolute path for the imported file
return dependencies
.map(depPath => resolveImportPaths(path.dirname(filePath), depPath))
.filter(path => path !== null)
}
// Main function to process a list of directories and create the Map of dependencies
function findJSAndImports(directories) {
const fileDependenciesMap = new Map()
directories.forEach(dir => {
if (fs.existsSync(dir)) {
const jsFiles = collectJsFiles(dir)
jsFiles.forEach(filePath => {
const imports = extractImports(filePath)
fileDependenciesMap.set(filePath, imports)
})
} else {
console.error(`Directory not found: ${dir}`)
process.exit(1)
}
})
return fileDependenciesMap
}
function printDirectoriesReport(allFilesAndImports) {
// collect all files that are imported via CommonJS in the entire backend codebase
const filesImportedViaCjs = new Set()
allFilesAndImports.forEach((imports, file) => {
if (!fileIsESM(file)) {
imports.forEach(imprt => filesImportedViaCjs.add(imprt))
}
})
// collect js files from the selected paths
const selectedFiles = Array.from(
findJSAndImports(paths.map(dir => path.resolve(dir))).keys()
).filter(file => !file.endsWith('settings.test.js'))
const nonMigratedFiles = selectedFiles.filter(fileCanBeConvertedToESM)
const migratedFileCount = selectedFiles.filter(fileIsESM).length
// collect files in the selected paths that are not imported via CommonJs in the entire backend codebase
const filesNotImportedViaCjs = nonMigratedFiles.filter(
file => !filesImportedViaCjs.has(file)
)
if (json) {
console.log(
JSON.stringify(
{
fileCount: selectedFiles.length,
migratedFileCount,
filesNotImportedViaCjs,
},
null,
2
)
)
} else {
console.log(`Found ${selectedFiles.length} files in ${paths}:
- ${migratedFileCount} have been migrated to ES Modules (progress=${((migratedFileCount / selectedFiles.length) * 100).toFixed(2)}%)
- ${filesNotImportedViaCjs.length} are ready to migrate (these are not imported via CommonJS in the entire codebase)
`)
if (files) {
console.log(`Files that are ready to migrate:`)
filesNotImportedViaCjs.forEach(file =>
console.log(` - ${file.replace(process.cwd() + '/', '')}`)
)
}
}
}
function printFileReport(allFilesAndImports) {
const filePath = path.resolve(paths[0])
if (fileIsESM(filePath)) {
console.log(`${filePath} is already migrated to ESM`)
return
}
const filePathWithoutExtension = filePath.replace('.js', '')
const importingFiles = []
allFilesAndImports.forEach((imports, file) => {
if (fileIsESM(file)) {
return
}
if (
imports.some(
imprt => imprt === filePath || imprt === filePathWithoutExtension
)
) {
importingFiles.push(file)
}
})
if (json) {
console.log(
JSON.stringify(
{
importingFiles,
},
null,
2
)
)
} else {
console.log(`${filePath} is required by ${importingFiles.length} CJS file`)
importingFiles.forEach(file =>
console.log(` - ${file.replace(process.cwd() + '/', '')}`)
)
}
}
function main() {
if (help) {
usage()
process.exit(0)
}
// collect all the js files in the entire backend codebase (app/ + modules/) with its imports
const allFilesAndImports = findJSAndImports(
APP_CODE_PATH.map(dir => path.resolve(dir))
)
const entryPoint = fs.existsSync('app.js') ? 'app.js' : 'app.mjs'
allFilesAndImports.set(path.resolve(entryPoint), extractImports(entryPoint))
const isFileReport = fs.statSync(paths[0]).isFile()
if (isFileReport) {
printFileReport(allFilesAndImports)
} else {
printDirectoriesReport(allFilesAndImports)
}
}
main()