Skip to content

Commit

Permalink
fix: correct ESM paths for dual CJS/ESM compatibility
Browse files Browse the repository at this point in the history
  • Loading branch information
nbouvrette committed Mar 3, 2024
1 parent 4fc1af9 commit 98f53c8
Show file tree
Hide file tree
Showing 9 changed files with 206 additions and 121 deletions.
4 changes: 4 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,10 @@ export default [
// @see https://eslint.org/docs/latest/rules/curly
curly: ['error'],
// The Unicorn plugin comes with opinionated checks, including some that we prefer disabling.
'unicorn/no-array-reduce': [
// 'reduce' is a powerful method for functional programming patterns, use it when appropriate.
'off',
],
'unicorn/no-array-for-each': [
// Performance is no longer an issue - we prefer `forEach` for readability.
'off',
Expand Down
14 changes: 7 additions & 7 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@
],
"scripts": {
"add-file-type-declaration": "node ./lib/esm/add-import-type.js && find ./lib -name 'add-import-type.*' -type f -delete",
"build": "check-node-version --node '>=16' && npm run prettier && npm run lint-fix && rm -Rf ./lib && tsc -p tsconfig.cjs.json && tsc -p tsconfig.esm.json && echo '{ \"type\": \"commonjs\" }' > lib/cjs/package.json && tsc -p tsconfig.build-scripts.json && node lib/build-scripts/run-all.js && npm run test",
"build": "check-node-version --node '>=16' && npm run prettier && npm run lint-fix && rm -Rf ./lib && tsc -p tsconfig.cjs.json && tsc -p tsconfig.esm.json && echo '{ \"type\": \"commonjs\" }' > lib/cjs/package.json && tsc -p tsconfig.build-scripts.json && node lib/build-scripts/build.js && npm run test",
"ci": "npm run build",
"lint-fix": "eslint --fix .",
"prettier": "prettier --write .",
Expand All @@ -94,7 +94,7 @@
"devDependencies": {
"@release-it/conventional-changelog": "8.0.1",
"@types/jest": "29.5.12",
"@types/node": "^20.11.22",
"@types/node": "^20.11.24",
"@typescript-eslint/eslint-plugin": "7.1.0",
"@typescript-eslint/parser": "7.1.0",
"check-node-version": "^4.2.1",
Expand Down
67 changes: 0 additions & 67 deletions src/build-scripts/add-file-type-declaration.ts

This file was deleted.

189 changes: 189 additions & 0 deletions src/build-scripts/build.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import { existsSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
import { EOL } from 'node:os'
import { dirname, join, relative, resolve } from 'node:path'
import { minify_sync as minify } from 'terser'

/**
* +------------------------------------------------------------------+
* | Add file type declarations |
* +------------------------------------------------------------------+
*/

console.log(`🏃 Running build step: add file type declarations.${EOL}`)

/**
* Append a type declaration to another type file.
*
* @param targetTypeFilePath - The path of the target type file.
* @param typeDeclarationFilePath - The path of the type declaration file.
*/
const appendFileTypeDeclaration = (
targetTypeFilePath: string,
typeDeclarationFilePath: string
): void => {
console.log(
` 🔗 Adding file type declaration (${typeDeclarationFilePath}) to ${targetTypeFilePath}`
)
writeFileSync(
targetTypeFilePath,
`${readFileSync(targetTypeFilePath).toString()}${EOL}// Enables type recognition for direct \`.properties\` file imports.${EOL}import '${typeDeclarationFilePath}'${EOL}`
)
}

// Since the package supports both CommonJS and ECMAScript modules, we need to add the type reference to both.
const declarationDirectoryPaths = ['lib/cjs', 'lib/esm']

/**
* Since the type file to open `.properties` files is not a module and cannot be copied, we need to
* copy it explicitly after the build.
*/
const declarationFilename = 'properties-file'
const declarationFileContent = readFileSync(`./src/${declarationFilename}.d.ts`).toString()
declarationDirectoryPaths.forEach((modulePath) => {
console.log(
` 🧬 Copying ./src/${declarationFilename}.d.ts to ${modulePath}/${declarationFilename}.d.ts`
)
writeFileSync(`${modulePath}/${declarationFilename}.d.ts`, declarationFileContent)
})

/**
* Now that the declaration file is copied, we can reference it in the main package's module types.
*/
declarationDirectoryPaths.forEach((modulePath) => {
appendFileTypeDeclaration(`${modulePath}/index.d.ts`, `./${declarationFilename}.d.ts`)
})

/**
* Since the most common use case to support to require the file type declaration is by configuring
* a loader, we need to also add the reference in all available loaders.
*
* @see https://github.com/microsoft/TypeScript/issues/49124
* @see https://stackoverflow.com/questions/72187763/how-to-include-a-global-file-type-declaration-in-a-typescript-node-js-package
*/

declarationDirectoryPaths.forEach((modulePath) => {
const fileLoaderDirectoryPath = `${modulePath}/loader`
const fileLoaderFilePaths = readdirSync(fileLoaderDirectoryPath)
fileLoaderFilePaths.forEach((fileLoaderFilePath) => {
if (fileLoaderFilePath.endsWith('d.ts')) {
appendFileTypeDeclaration(
`./${fileLoaderDirectoryPath}/${fileLoaderFilePath}`,
`../${declarationFilename}.d.ts`
)
}
})
})

/**
* +-----------------------------------------------------------------+
* | Add ESM file extensions |
* +-----------------------------------------------------------------+
*/

const esmFileExtensionRegExp =
/(?<from>from\s*)(?<quote>["'])(?<path>(?!.*\.js)(\.|\.?\.\/.*?)\.?)(\k<quote>)/gm

/**
* Get all ESM file paths (`.js` and `.d.ts`) from a directory.
*
* @param esmBuildDirectoryPath - The path to the ESM build directory.
*
* @returns An array of ESM file paths.
*/
const getEsmFilePaths = (esmBuildDirectoryPath: string): string[] =>
readdirSync(esmBuildDirectoryPath, { withFileTypes: true }).reduce<string[]>((files, entry) => {
const absoluteEntryPath = resolve(esmBuildDirectoryPath, entry.name)
const relativeEntryPath = relative(process.cwd(), absoluteEntryPath)
if (entry.isDirectory()) {
return [...files, ...getEsmFilePaths(absoluteEntryPath)]
} else if (entry.isFile() && /\.(d\.ts|js)$/.test(absoluteEntryPath)) {
return [...files, relativeEntryPath]
}
return files
}, [])

console.log(`${EOL}🏃 Running build step: add ESM file extensions.${EOL}`)

getEsmFilePaths('lib/esm').forEach((filePath) => {
const fileContent = readFileSync(filePath).toString()
const newFileContent = fileContent.replace(
esmFileExtensionRegExp,
(_match, from: string, quote: string, path: string) => {
const fromPath = resolve(join(dirname(filePath), path))

// If the path exists without any extensions then it should be a directory.
const fromPathIsDirectory = existsSync(fromPath)

if (fromPathIsDirectory && !statSync(fromPath).isDirectory()) {
throw new Error(`🚨 Expected ${fromPathIsDirectory} to be a directory`)
}

// Add the missing extension or `/index` to the path to make it ESM compatible.
const esmPath = fromPathIsDirectory ? `${fromPath}/index.js` : `${fromPath}.js`

if (!existsSync(esmPath)) {
throw new Error(`🚨 File not found: ${esmPath}`)
}

if (!statSync(esmPath).isFile()) {
throw new Error(`🚨 Expected ${fromPathIsDirectory} to be a file`)
}

const newPath = `${path}${fromPathIsDirectory ? '/index' : ''}.js`
console.log(` ➕ ${filePath}: replacing "${path}" by "${newPath}"`)
return `${from}${quote}${newPath}${quote}`
}
)

writeFileSync(filePath, newFileContent)
})

/**
* +----------------------------------------------------------------+
* | Minify build |
* +----------------------------------------------------------------+
*/

/**
* Get all JavaScript file paths (`.js`) from a build directory.
*
* @param esmBuildDirectoryPath - The path to the build directory.
*
* @returns An array of JavaScript file paths.
*/
const getJsFilePaths = (buildDirectoryPath: string): string[] =>
readdirSync(buildDirectoryPath, { withFileTypes: true }).reduce<string[]>((files, entry) => {
const absoluteEntryPath = resolve(buildDirectoryPath, entry.name)
const relativeEntryPath = relative(process.cwd(), absoluteEntryPath)
if (entry.isDirectory()) {
return [...files, ...getJsFilePaths(absoluteEntryPath)]
} else if (entry.isFile() && /\.js$/.test(absoluteEntryPath)) {
return [...files, relativeEntryPath]
}
return files
}, [])

const minifyBuildDirectoryPaths = ['lib/cjs', 'lib/esm']

console.log(`${EOL}🏃 Running build script: minify build.${EOL}`)

minifyBuildDirectoryPaths.forEach((buildDirectoryPath) => {
getJsFilePaths(buildDirectoryPath).forEach((filePath) => {
const result = minify(readFileSync(filePath).toString())
if (result?.code === undefined) {
throw new Error('Minification failed')
}
console.log(` 📦 Minifying file: ${filePath}`)
writeFileSync(filePath, result.code)
})
})

/**
* +------------------------------------------------------------------+
* | Delete build scripts |
* +------------------------------------------------------------------+
*/

console.log(`${EOL}🏃 Running build script: delete build scripts.${EOL}`)

rmSync('lib/build-scripts', { recursive: true, force: true })
4 changes: 0 additions & 4 deletions src/build-scripts/delete-build-scripts.ts

This file was deleted.

30 changes: 0 additions & 30 deletions src/build-scripts/minify-build.ts

This file was deleted.

8 changes: 0 additions & 8 deletions src/build-scripts/run-all.ts

This file was deleted.

7 changes: 4 additions & 3 deletions src/properties-file.d.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
/** This allows to get the correct type when using `import` on `.properties` files. */
declare module '*.properties' {
const properties: {
/** A key/value object representing the content of a `.properties` file. */
const keyValuePairObject: {
/** The value of a `.properties` file key. */
readonly [key: string]: string
}
export default properties
export = keyValuePairObject
}

0 comments on commit 98f53c8

Please sign in to comment.