diff --git a/.ocularrc.js b/.ocularrc.js index 2d7ec2dd..ed5ce030 100644 --- a/.ocularrc.js +++ b/.ocularrc.js @@ -7,6 +7,8 @@ let ocularConfig = { check: true }, + babel: false, + lint: { paths: ['modules'] }, diff --git a/modules/dev-tools/package.json b/modules/dev-tools/package.json index 3ed9b9ca..0424f10e 100644 --- a/modules/dev-tools/package.json +++ b/modules/dev-tools/package.json @@ -15,11 +15,14 @@ "src", "scripts", "templates", + "ts-plugins", "CHANGELOG.md" ], "exports": { ".": "./src/index.js", - "./configuration": "./src/configuration/index.cjs" + "./configuration": "./src/configuration/index.cjs", + "./ts-transform-version-inline": "./ts-plugins/ts-transform-version-inline/index.cjs", + "./ts-transform-append-extension": "./ts-plugins/ts-transform-append-extension/index.cjs" }, "types": "./src/index.d.ts", "main": "./src/index.js", @@ -39,7 +42,7 @@ "bootstrap": "yarn install-fast && ocular-bootstrap", "install-fast": "PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true yarn", "clean": "echo No build needed", - "build": "echo No build needed", + "build": "tsc && find ./ts-plugins -depth -name \"*.js\" -exec sh -c 'f=\"{}\"; mv -- \"$f\" \"${f%.js}.cjs\"' \\;", "lint": "npm run lint-yarn", "lint-yarn": "!(grep -q unpm.u yarn.lock) || (echo 'Please rebuild yarn file using public npmrc' && false)", "publish-prod": "npm run build && npm run test && npm run test dist && npm publish", @@ -86,6 +89,7 @@ "tape-promise": "^4.0.0", "typescript": "^5.2.2", "ts-node": "~10.9.0", + "ts-patch": "^3.1.2", "tsconfig-paths": "^4.1.1", "url": "^0.11.0", "vite": "^4.0.1", diff --git a/modules/dev-tools/scripts/build.sh b/modules/dev-tools/scripts/build.sh index bc1b45ec..57542f85 100755 --- a/modules/dev-tools/scripts/build.sh +++ b/modules/dev-tools/scripts/build.sh @@ -20,25 +20,15 @@ build_src() { OUT_DIR=$1 TARGET=$2 check_target $TARGET - (set -x; BABEL_ENV=$TARGET npx babel src --config-file $CONFIG --out-dir $OUT_DIR --copy-files --source-maps --extensions $EXTENSIONS) + + if [ ! -z "$CONFIG" ]; then + (set -x; BABEL_ENV=$TARGET npx babel src --config-file $CONFIG --out-dir $OUT_DIR --copy-files --source-maps --extensions $EXTENSIONS) + fi } build_module_esm() { build_src dist esm-strict - - # build cjs bundles - CJS_ENTRIES=`node $DEV_TOOLS_DIR/src/helpers/get-cjs-entry-points.js | sed -E "s/,/ /g"` - - for P in ${CJS_ENTRIES}; do ( - if [ -e "src/${P}.ts" ]; then - ENTRY=src/${P}.ts - else - ENTRY=src/${P}.js - fi - - echo "Bundling ${ENTRY}" - esbuild $ENTRY --bundle --packages=external --format=cjs --target=node16 --outfile=dist/${P}.cjs - ); done + node $DEV_TOOLS_DIR/src/build-cjs.js } build_module() { @@ -108,7 +98,7 @@ build_monorepo() { } if [ ! -z "$TS_PROJECT" ]; then - tsc -b $TS_PROJECT + tspc -b $TS_PROJECT --verbose fi if [ -d "modules" ]; then diff --git a/modules/dev-tools/scripts/bundle.js b/modules/dev-tools/scripts/bundle.js index 084dd912..12cd495c 100755 --- a/modules/dev-tools/scripts/bundle.js +++ b/modules/dev-tools/scripts/bundle.js @@ -1,7 +1,7 @@ #!/usr/bin/env node import esbuild from 'esbuild'; -import getBuildConfig from '../src/configuration/esbuild.config.js'; +import {getBundleConfig} from '../src/configuration/get-esbuild-config.js'; // Parse command line arguments let entryPoint; @@ -17,7 +17,7 @@ for (let i = 1; i < process.argv.length; i++) { } } -const buildConfig = await getBuildConfig({ +const buildConfig = await getBundleConfig({ ...env, input: entryPoint }); diff --git a/modules/dev-tools/src/build-cjs.js b/modules/dev-tools/src/build-cjs.js new file mode 100644 index 00000000..4842a421 --- /dev/null +++ b/modules/dev-tools/src/build-cjs.js @@ -0,0 +1,27 @@ +import esbuild from 'esbuild'; +import fs from 'fs/promises'; +import {getCJSEntryPoints} from './helpers/get-cjs-entry-points.js'; +import {getCJSExportConfig} from './configuration/get-esbuild-config.js'; + +async function main() { + for (const fileName of getCJSEntryPoints()) { + const inputPath = `./dist/${fileName}.js`; + try { + await fs.stat(inputPath); + + const esbuildConfig = await getCJSExportConfig({ + input: inputPath, + output: `dist/${fileName}.cjs` + }); + const result = await esbuild.build(esbuildConfig); + if (result.errors.length > 0) { + process.exit(1); + } + } catch { + // File does not exist + console.error(`\x1b[33mCannot find file ${inputPath}\x1b[0m`); + } + } +} + +main(); diff --git a/modules/dev-tools/src/configuration/esbuild.config.js b/modules/dev-tools/src/configuration/get-esbuild-config.js similarity index 81% rename from modules/dev-tools/src/configuration/esbuild.config.js rename to modules/dev-tools/src/configuration/get-esbuild-config.js index 84bb1306..25b11d64 100644 --- a/modules/dev-tools/src/configuration/esbuild.config.js +++ b/modules/dev-tools/src/configuration/get-esbuild-config.js @@ -3,7 +3,6 @@ import fs from 'fs'; import {join} from 'path'; import util from 'util'; import {getOcularConfig} from '../helpers/get-ocular-config.js'; -import babel from 'esbuild-plugin-babel'; import ext from 'esbuild-plugin-external-global'; /** @@ -36,25 +35,6 @@ function getExternalGlobalsIIFE(externalPackages, mapping) { return externals; } -/** Evaluate root babel config */ -async function getBabelConfig(configPath, env, target) { - let config = await import(configPath); - if (config.default) { - config = config.default; - } - if (typeof config === 'function') { - config = config({ - env: () => env - }); - } - const envPreset = config.presets.find((item) => item[0] === '@babel/env'); - if (target && envPreset) { - envPreset[1] = envPreset[1] || {}; - envPreset[1].targets = target; - } - return config; -} - // esbuild does not support umd format // Work around from https://github.com/evanw/esbuild/issues/819 // Template: https://webpack.js.org/configuration/output/#type-umd @@ -86,38 +66,52 @@ function umdWrapper(libName) { }; } +/** + * + * @param {String} opts.input - path to entry point + * @param {String} opts.output - output file path + */ +export async function getCJSExportConfig(opts) { + return { + entryPoints: [opts.input], + outfile: opts.output, + bundle: true, + format: 'cjs', + // Node 16 is out of support, kept for compatibility. Move to 18? + target: 'node16', + packages: 'external', + sourcemap: true, + logLevel: 'info' + }; +} + /* eslint-disable max-statements,complexity */ -export default async function getBundleConfig(opts) { +export async function getBundleConfig(opts) { // This script must be executed in a submodule's directory const packageRoot = process.cwd(); const packageInfo = JSON.parse(fs.readFileSync(join(packageRoot, 'package.json'), 'utf-8')); + + const devMode = opts.env === 'dev'; + const ocularConfig = await getOcularConfig({ - root: join(packageRoot, '../..') + root: join(packageRoot, '../..'), + aliasMode: devMode ? 'src' : 'dist' }); opts = {...ocularConfig.bundle, ...opts}; - const devMode = opts.env === 'dev'; const { input, output = devMode ? './dist/dist.dev.js' : './dist.min.js', format = 'iife', - target, + target = ['esnext'], externals, globalName, debug, sourcemap = false } = opts; - const babelConfig = devMode - ? { - filter: /src|bundle/, - config: await getBabelConfig(ocularConfig.babel.configPath, 'bundle-dev', target) - } - : { - filter: /src|bundle|esm/, - config: await getBabelConfig(ocularConfig.babel.configPath, 'bundle', target) - }; + let babelConfig; let externalPackages = Object.keys(packageInfo.peerDependencies || {}); if (typeof externals === 'string') { @@ -134,10 +128,10 @@ export default async function getBundleConfig(opts) { minify: !devMode, alias: ocularConfig.aliases, platform: 'browser', - target: ['esnext'], + target, logLevel: 'info', sourcemap, - plugins: [babel(babelConfig)] + plugins: [] }; if (globalName) { config.globalName = globalName; diff --git a/modules/dev-tools/src/helpers/get-cjs-entry-points.js b/modules/dev-tools/src/helpers/get-cjs-entry-points.js index b5f8ce78..c27eab4c 100644 --- a/modules/dev-tools/src/helpers/get-cjs-entry-points.js +++ b/modules/dev-tools/src/helpers/get-cjs-entry-points.js @@ -1,16 +1,7 @@ import fs from 'fs'; import {basename} from 'path'; -try { - const entryPoints = await getCJSEntryPoints(); - console.log(entryPoints.join(',')); -} catch (ex) { - console.error('Error reading package entry points'); - console.error(ex); - process.exit(1); -} - -function getCJSEntryPoints() { +export function getCJSEntryPoints() { const packageInfo = JSON.parse(fs.readFileSync('package.json', 'utf-8')); if (packageInfo.exports) { diff --git a/modules/dev-tools/src/helpers/get-config.js b/modules/dev-tools/src/helpers/get-config.js index 2434bc78..576ee81b 100644 --- a/modules/dev-tools/src/helpers/get-config.js +++ b/modules/dev-tools/src/helpers/get-config.js @@ -1,3 +1,9 @@ +/** + * Used by command line scripts to print a field from the local ocular config. + Path is period separated. + Example: + $ node get-config.js ".babel.configPath" + */ import {getOcularConfig} from '../helpers/get-ocular-config.js'; let ocularConfig; @@ -18,7 +24,7 @@ configPath .split('.') .filter(Boolean) .forEach((path) => { - config = config[path]; + config = config ? config[path] : undefined; }); if (config === undefined) { diff --git a/modules/dev-tools/src/helpers/get-ocular-config.js b/modules/dev-tools/src/helpers/get-ocular-config.js index 6255b8e1..9fce5d11 100644 --- a/modules/dev-tools/src/helpers/get-ocular-config.js +++ b/modules/dev-tools/src/helpers/get-ocular-config.js @@ -11,22 +11,27 @@ export async function getOcularConfig(options = {}) { const IS_MONOREPO = fs.existsSync(resolve(packageRoot, './modules')); + const userConfig = await getUserConfig(packageRoot, options); + const config = { root: packageRoot, ocularPath: ocularRoot, esm: getModuleInfo(packageRoot).packageInfo.type === 'module', - babel: { - configPath: getValidPath( - resolve(packageRoot, './.babelrc'), - resolve(packageRoot, './.babelrc.js'), - resolve(packageRoot, './.babelrc.cjs'), - resolve(packageRoot, './babel.config.js'), - resolve(packageRoot, './babel.config.cjs') - ), - extensions: ['.js', '.jsx', '.mjs', '.ts', '.tsx'] - }, + babel: + userConfig.babel !== false + ? { + configPath: getValidPath( + resolve(packageRoot, './.babelrc'), + resolve(packageRoot, './.babelrc.js'), + resolve(packageRoot, './.babelrc.cjs'), + resolve(packageRoot, './babel.config.js'), + resolve(packageRoot, './babel.config.cjs') + ), + extensions: ['.js', '.jsx', '.mjs', '.ts', '.tsx'] + } + : null, bundle: { globals: {} @@ -61,8 +66,6 @@ export async function getOcularConfig(options = {}) { } }; - const userConfig = await getUserConfig(packageRoot, options); - shallowMerge(config, userConfig); // Backward compatibility diff --git a/modules/dev-tools/ts-plugins/.gitignore b/modules/dev-tools/ts-plugins/.gitignore new file mode 100644 index 00000000..cf16a28b --- /dev/null +++ b/modules/dev-tools/ts-plugins/.gitignore @@ -0,0 +1 @@ +*.cjs \ No newline at end of file diff --git a/modules/dev-tools/ts-plugins/ts-transform-append-extension/index.ts b/modules/dev-tools/ts-plugins/ts-transform-append-extension/index.ts new file mode 100644 index 00000000..21c93a8b --- /dev/null +++ b/modules/dev-tools/ts-plugins/ts-transform-append-extension/index.ts @@ -0,0 +1,91 @@ +/** + * TypeScript transform to append file extension to import statements in the compiled JS files + * Usage with ts-patch: + { + "plugins": [ + { + "transform": "ocular-dev-tools/ts-transform-append-extension", + "extensions": [".js"], + "after": true + } + ] + } + * Adapted from https://github.com/murolem/ts-transformer-append-js-extension to support custom extensions + */ +import * as path from 'path'; +import type {Program, TransformationContext, SourceFile, Node} from 'typescript'; +import type {TransformerExtras, PluginConfig} from 'ts-patch'; + +type AppendExtensionPluginConfig = PluginConfig & { + /** List of file extensions, for example: + * '.js': applies to paths without an extension, e.g. `import {add} from './math'` => `import {add} from './math.js'` + * '.lib.cjs': applies to paths ending with .lib, e.g. `import fft from './fft.lib` => `import fft from './fft.lib.cjs'` + * @default [".js"] + */ + extensions?: string[]; +}; + +export default function ( + program: Program, + pluginConfig: AppendExtensionPluginConfig, + {ts}: TransformerExtras +) { + // only append .js when module specifier has no extension or user-provided extensions + const {extensions = ['.js']} = pluginConfig; + const extMappings = new Map(); + for (const ext of extensions) { + const addition = path.extname(ext) || ext; + const base = path.basename(ext, addition); + extMappings.set(base, addition); + } + + function shouldMutateModuleSpecifier(node: Node): string | false { + if (!ts.isImportDeclaration(node) && !ts.isExportDeclaration(node)) return false; + if (node.moduleSpecifier === undefined) return false; + // only when module specifier is valid + if (!ts.isStringLiteral(node.moduleSpecifier)) return false; + // only when path is relative + if (!node.moduleSpecifier.text.startsWith('./') && !node.moduleSpecifier.text.startsWith('../')) + return false; + // only when module specifier has accepted extension + const ext = path.extname(node.moduleSpecifier.text); + if (!extMappings.has(ext)) return false; + return node.moduleSpecifier.text + extMappings.get(ext); + } + + return (ctx: TransformationContext) => { + const {factory} = ctx; + + return (sourceFile: SourceFile) => { + function visit(node: Node): Node { + const newImportSource = shouldMutateModuleSpecifier(node); + if (newImportSource) { + if (ts.isImportDeclaration(node)) { + const newModuleSpecifier = factory.createStringLiteral(newImportSource); + node = factory.updateImportDeclaration( + node, + node.modifiers, + node.importClause, + newModuleSpecifier, + node.assertClause + ); + } else if (ts.isExportDeclaration(node)) { + const newModuleSpecifier = factory.createStringLiteral(newImportSource); + node = factory.updateExportDeclaration( + node, + node.modifiers, + node.isTypeOnly, + node.exportClause, + newModuleSpecifier, + node.assertClause + ); + } + } + + return ts.visitEachChild(node, visit, ctx); + } + + return ts.visitNode(sourceFile, visit); + }; + }; +} diff --git a/modules/dev-tools/ts-plugins/ts-transform-version-inline/index.ts b/modules/dev-tools/ts-plugins/ts-transform-version-inline/index.ts new file mode 100644 index 00000000..76afa0a0 --- /dev/null +++ b/modules/dev-tools/ts-plugins/ts-transform-version-inline/index.ts @@ -0,0 +1,73 @@ +/** + * TypeScript transform to inject the current version of the package as string. + * Usage with ts-patch: + { + "plugins": [ + { + "transform": "ocular-dev-tools/ts-transform-version-inline", + "identifier": "PACKAGE_VERSION" + } + ] + } + */ +import * as fs from 'fs'; +import * as path from 'path'; +import type {Program, TransformationContext, SourceFile, Node} from 'typescript'; +import type {TransformerExtras, PluginConfig} from 'ts-patch'; + +type VersionInlinePluginConfig = PluginConfig & { + /** Identifier name to replace in code. + * @default "__VERSION__" + */ + identifier?: string; +}; + +export default function ( + program: Program, + pluginConfig: VersionInlinePluginConfig, + {ts}: TransformerExtras +) { + const {identifier = '__VERSION__'} = pluginConfig; + + return (ctx: TransformationContext) => { + const {factory} = ctx; + + return (sourceFile: SourceFile) => { + let packageVersion: string | null; + + function visit(node: Node): Node { + if (ts.isIdentifier(node) && node.getText() === identifier) { + if (packageVersion === undefined) { + packageVersion = getPackageVersion(sourceFile.fileName); + } + if (packageVersion) { + return factory.createStringLiteral(packageVersion); + } + } + return ts.visitEachChild(node, visit, ctx); + } + return ts.visitNode(sourceFile, visit); + }; + }; +} + +/** + * Retrieve the version string from the closest package.json + */ +function getPackageVersion(fileName: string): string | null { + let currentDir = fileName; + while (currentDir !== '/') { + try { + currentDir = path.dirname(currentDir); + const packageJson = path.join(currentDir, 'package.json'); + const stat = fs.statSync(packageJson); + if (stat.isFile()) { + const content = fs.readFileSync(packageJson, 'utf8'); + return JSON.parse(content).version as string; + } + } catch { + // file does not exist, try going up + } + } + return null; +} diff --git a/modules/dev-tools/tsconfig.json b/modules/dev-tools/tsconfig.json index 0ba7297d..698470f0 100644 --- a/modules/dev-tools/tsconfig.json +++ b/modules/dev-tools/tsconfig.json @@ -1,18 +1,18 @@ { "compilerOptions": { "target": "esnext", - "module": "esnext", - "allowJs": true, + "module": "commonjs", + "allowJs": false, "checkJs": false, "moduleResolution": "node", "esModuleInterop": true, "allowSyntheticDefaultImports": true, - "noEmit": true, "baseUrl": ".", "skipLibCheck": true, "strict": true }, "include":[ - "src/**/*" + "src/**/*", + "ts-plugins/**/*" ] } diff --git a/yarn.lock b/yarn.lock index 0d59052d..6f609e5a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3383,7 +3383,7 @@ chalk@^2.3.1, chalk@^2.4.2: escape-string-regexp "^1.0.5" supports-color "^5.3.0" -chalk@^4.0.0, chalk@^4.0.2: +chalk@^4.0.0, chalk@^4.0.2, chalk@^4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== @@ -5325,6 +5325,15 @@ glob@^7.1.1, glob@^7.1.3, glob@^7.1.4, glob@^7.2.0, glob@~7.2.3: once "^1.3.0" path-is-absolute "^1.0.0" +global-prefix@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/global-prefix/-/global-prefix-3.0.0.tgz#fc85f73064df69f50421f47f883fe5b913ba9b97" + integrity sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg== + dependencies: + ini "^1.3.5" + kind-of "^6.0.2" + which "^1.3.1" + globals@^11.1.0: version "11.12.0" resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" @@ -5697,7 +5706,7 @@ inherits@2, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.3, resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== -ini@^1.3.2, ini@^1.3.4: +ini@^1.3.2, ini@^1.3.4, ini@^1.3.5: version "1.3.8" resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== @@ -6859,7 +6868,7 @@ minimist-options@^3.0.1: arrify "^1.0.1" is-plain-obj "^1.1.0" -minimist@^1.1.3, minimist@^1.2.0, minimist@^1.2.5, minimist@^1.2.6, minimist@~1.2.8: +minimist@^1.1.3, minimist@^1.2.0, minimist@^1.2.5, minimist@^1.2.6, minimist@^1.2.8, minimist@~1.2.8: version "1.2.8" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== @@ -8327,7 +8336,7 @@ resolve-url@^0.2.1: resolved "https://registry.yarnpkg.com/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a" integrity sha512-ZuF55hVUQaaczgOIwqWzkEcEidmlD/xl44x1UZnhOXcYuFN2S6+rcxpG+C1N3So0wvNI3DmJICUFfu2SxhBmvg== -resolve@^1.10.0, resolve@^1.14.2, resolve@^1.22.4, resolve@~1.22.6: +resolve@^1.10.0, resolve@^1.14.2, resolve@^1.22.2, resolve@^1.22.4, resolve@~1.22.6: version "1.22.8" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.8.tgz#b6c87a9f2aa06dfab52e3d70ac8cde321fa5a48d" integrity sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw== @@ -9286,6 +9295,18 @@ ts-node@~10.9.0: v8-compile-cache-lib "^3.0.1" yn "3.1.1" +ts-patch@^3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/ts-patch/-/ts-patch-3.1.2.tgz#9d4832eca34ed0b9eb1f8456cb00c941f50b442b" + integrity sha512-n58F5AqjUMdp9RAKq+E1YBkmONltPVbt1nN+wrmZXoYZek6QcvaTuqvKMhYhr5BxtC53kD/exxIPA1cP1RQxsA== + dependencies: + chalk "^4.1.2" + global-prefix "^3.0.0" + minimist "^1.2.8" + resolve "^1.22.2" + semver "^7.5.4" + strip-ansi "^6.0.1" + tsconfig-paths@^3.15.0: version "3.15.0" resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz#5299ec605e55b1abb23ec939ef15edaf483070d4"