diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..b03f4086 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +lts/krypton diff --git a/.storybook/main.js b/.storybook/main.js index cbf56f51..649d43b5 100644 --- a/.storybook/main.js +++ b/.storybook/main.js @@ -1,5 +1,5 @@ -const sass = require("sass"); const path = require("path"); +const sass = require("sass"); const sassRenderSyncConfig = require("./../scripts/sassConfig"); const { silenceDeprecations } = require("../scripts/sassDeprecationConfig"); @@ -9,15 +9,6 @@ module.exports = { "@storybook/addon-links", "@storybook/addon-docs", "@storybook/addon-a11y", - { - name: "@storybook/preset-scss", - options: { - sassLoaderOptions: { - implementation: sass, - sassOptions: {...sassRenderSyncConfig, silenceDeprecations}, - }, - }, - }, "@storybook/addon-webpack5-compiler-babel", ], framework: { @@ -64,6 +55,16 @@ module.exports = { }, }, webpackFinal: async (config, { configType }) => { + // never let the compiled package output take part in the Storybook build, + // stories always have to use the sources + // a directory import like `"./../../../../"` is resolved by the `exports` field of our + // own package.json and would silently pull `dist/esm/` into the preview bundle, + // this restriction removes it from the candidates so that the root `index.ts` is used + const distPath = path.resolve(__dirname, "..", "dist"); + config.resolve.restrictions = [ + ...(config.resolve.restrictions ?? []), + new RegExp(`^(?!${distPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[/\\\\])`), + ]; // `configType` has a value of 'DEVELOPMENT' or 'PRODUCTION' if (configType === "PRODUCTION") { // remove source maps from production storybook @@ -72,13 +73,29 @@ module.exports = { config.devtool = false; } config.module.rules = [ + { + test: /\.s[ac]ss$/i, + use: [ + require.resolve("style-loader"), + { + loader: require.resolve("css-loader"), + options: { + importLoaders: 1, + }, + }, + { + loader: require.resolve("sass-loader"), + options: { + implementation: sass, + sassOptions: { ...sassRenderSyncConfig, silenceDeprecations }, + }, + }, + ], + }, { test: /\.(png|jpg|gif|svg)(\\?.*)?$/, include: /\.tobase64\./, - loader: "url-loader", - options: { - limit: true, - }, + type: "asset/inline", }, ...config.module.rules.map((rule) => { if ( @@ -92,11 +109,6 @@ module.exports = { return rule; }), ]; - config.resolve.alias = { - ...config.resolve.alias, - // Fix nasty bug with importing from this package, Storybook webpack cannot resolve it otherwise - "@codemirror/legacy-modes": path.resolve(__dirname, "../node_modules/@codemirror/legacy-modes"), - }; return config; }, }; diff --git a/.storybook/preview.js b/.storybook/preview.js index be7d5ee2..dfd916d4 100644 --- a/.storybook/preview.js +++ b/.storybook/preview.js @@ -17,7 +17,7 @@ export const parameters = { const preview = { // Enables auto-generated documentation for all stories // @see https://storybook.js.org/docs/writing-docs/autodocs - tags: ['autodocs'], + tags: ["autodocs"], parameters, }; diff --git a/CHANGELOG.md b/CHANGELOG.md index 301b6e92..715decb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,10 +11,27 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - `` - `noDrag` parameter: Add the `nodrag` class to the Switch element. Default: `true` +### Changed + +- Upgrading base libraries + - Carbon, Codemirror, React-Flow +- minimum node version (`engines.node`) is `18.19.0` now + - the build of the ESM distribution needs a synchronous `import.meta.resolve`, which is only available since this version + ### Fixed - `` - fix description and story to point out that `PropertyValueList` need always to be used as wrapper +- BOM issue on compressed stylesheet + - first rule `selector` becomes `BOM:selector` that is valid but will never apply + - we fixed this problem by adding a dummy rule as first rule +- ESM distribution + - the imports of the `@codemirror/legacy-modes` modes were written with an additional `.js` suffix, but the `exports` map of this package only provides the extension-less sub paths, so they were expanded to unresolvable paths like `mode/jinja2.js.js` +- Storybook + - the `` story imported the package root directory, this way the `exports` field of our own `package.json` pulled the built `dist/esm/` output into the preview bundle instead of the sources, and the Storybook build failed as soon as `dist/` existed + - the webpack configuration excludes `dist/` from module resolution now, so the sources are always used even if a story references the package root +- Added explicitly `assert` dependency + - the linter of `` uses `jshint`, which imports `console-browserify`, and this package requires the node core modules `assert` and `util` without declaring them; bundlers based on webpack 5 do not provide shims for node core modules anymore, so the polyfill (and `util` together with it) is part of the delivery now ### Deprecated diff --git a/babel.config.json b/babel.config.json index e3abab77..2c3b19e1 100644 --- a/babel.config.json +++ b/babel.config.json @@ -1,8 +1,3 @@ { - "presets": ["@babel/preset-env", "@babel/preset-react", "@babel/preset-typescript", "@babel/preset-flow"], - "plugins": [ - ["@babel/plugin-transform-private-methods", { "loose": true }], - ["@babel/plugin-transform-private-property-in-object", { "loose": true }], - ["@babel/plugin-transform-class-properties", { "loose": true }] - ] + "presets": ["@babel/preset-env", "@babel/preset-react", "@babel/preset-typescript"] } diff --git a/package.json b/package.json index 51c9caa6..8f3bf94c 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "registry": "https://registry.npmjs.org" }, "engines": { - "node": ">=18.18.0" + "node": ">=18.19.0" }, "style": "src/index.scss", "main": "dist/cjs/index.js", @@ -71,18 +71,19 @@ "@blueprintjs/colors": "^5.1.16", "@blueprintjs/core": "6.8.1", "@blueprintjs/select": "6.1.1", - "@carbon/icons": "^11.80.0", - "@carbon/react": "^1.107.1", - "@codemirror/lang-html": "^6.4.11", + "@carbon/icons": "^11.87.0", + "@carbon/react": "^1.115.0", + "@codemirror/lang-html": "^6.4.12", "@codemirror/lang-javascript": "^6.2.5", "@codemirror/lang-json": "^6.0.2", - "@codemirror/lang-markdown": "^6.5.0", + "@codemirror/lang-markdown": "^6.5.2", "@codemirror/lang-sql": "^6.10.0", "@codemirror/lang-xml": "^6.1.0", "@codemirror/lang-yaml": "^6.1.3", - "@codemirror/legacy-modes": "^6.5.2", + "@codemirror/legacy-modes": "^6.5.3", "@mavrin/remark-typograf": "^2.2.0", - "@xyflow/react": "^12.9.3", + "@xyflow/react": "^12.11.5", + "assert": "^2.1.0", "classnames": "^2.5.1", "codemirror": "^6.0.2", "color": "^5.0.3", @@ -95,7 +96,7 @@ "react": "^18.2.0", "react-dom": "^18.3.1", "react-flow-renderer": "9.7.4", - "react-inlinesvg": "^4.4.1", + "react-inlinesvg": "^4.5.0", "react-is": "^18.3.1", "react-markdown": "^10.1.0", "react-syntax-highlighter": "^16.1.1", @@ -106,73 +107,70 @@ "remark-parse": "^11.0.0", "reset-css": "^5.0.2", "unified": "^11.0.5", + "util": "^0.12.5", "wicg-inert": "^3.1.3" }, "devDependencies": { - "@babel/core": "^7.29.0", - "@babel/plugin-transform-class-properties": "^7.28.6", - "@babel/plugin-transform-private-methods": "^7.28.6", - "@babel/plugin-transform-private-property-in-object": "^7.28.6", - "@babel/preset-env": "^7.29.5", - "@babel/preset-flow": "^7.27.1", - "@babel/preset-react": "^7.28.5", - "@babel/preset-typescript": "^7.28.5", + "@babel/core": "^7.29.7", + "@babel/preset-env": "^7.29.7", + "@babel/preset-react": "^7.29.7", + "@babel/preset-typescript": "^7.29.7", "@eslint/compat": "^2.1.0", - "@eslint/eslintrc": "^3.3.5", + "@eslint/eslintrc": "^3.3.6", "@eslint/js": "^10.0.1", - "@storybook/addon-a11y": "^10.4.0", - "@storybook/addon-docs": "^10.4.0", - "@storybook/addon-links": "^10.4.0", + "@storybook/addon-a11y": "^10.5.10", + "@storybook/addon-docs": "^10.5.10", + "@storybook/addon-links": "^10.5.10", "@storybook/addon-webpack5-compiler-babel": "^4.0.1", - "@storybook/cli": "^10.4.0", - "@storybook/preset-scss": "^1.0.3", - "@storybook/react": "^10.4.0", - "@storybook/react-webpack5": "^10.4.0", + "@storybook/cli": "^10.5.10", + "@storybook/react": "^10.5.10", + "@storybook/react-webpack5": "^10.5.10", "@testing-library/dom": "^10.4.1", - "@testing-library/jest-dom": "^6.9.1", + "@testing-library/jest-dom": "^7.0.1", "@testing-library/react": "^16.3.2", "@types/he": "^1.2.3", "@types/jest": "^30.0.0", "@types/jshint": "^2.12.4", - "@types/lodash": "^4.17.24", + "@types/lodash": "^4.17.25", "@types/n3": "^1.26.1", "@types/react-dom": "^18.3.7", "@types/react-is": "^19.2.0", "@types/react-syntax-highlighter": "^15.5.13", - "@typescript-eslint/eslint-plugin": "^8.59.3", - "@typescript-eslint/parser": "^8.59.3", + "@typescript-eslint/eslint-plugin": "^8.68.0", + "@typescript-eslint/parser": "^8.68.0", "babel-jest": "^30.4.1", - "chromatic": "^16.10.1", - "eslint": "^10.3.0", + "chromatic": "^18.6.1", + "css-loader": "^7.1.4", + "eslint": "^10.9.1", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.1.1", - "eslint-plugin-simple-import-sort": "^13.0.0", + "eslint-plugin-simple-import-sort": "^14.0.0", "husky": "^4.3.8", "identity-obj-proxy": "^3.0.0", "jest": "^30.4.2", "jest-environment-jsdom": "^30.4.1", "jest-fixed-jsdom": "^0.0.11", - "jest-pnp-resolver": "^1.2.3", "lint-staged": "^15.5.2", "node-sass-package-importer": "^5.3.3", "path": "^0.12.7", - "prettier": "^3.8.3", + "prettier": "^3.9.6", "react-app-polyfill": "^3.0.0", "react-lorem-ipsum": "^1.4.9", "regenerator-runtime": "^0.14.1", "rimraf": "^6.1.3", - "sass": "^1.99.0", - "sass-loader": "^16.0.8", - "storybook": "^10.4.0", - "stylelint": "^17.11.0", - "stylelint-config-recess-order": "^7.7.0", + "sass": "^1.103.1", + "sass-loader": "^17.0.0", + "storybook": "^10.5.10", + "style-loader": "^4.0.0", + "stylelint": "^17.14.1", + "stylelint-config-recess-order": "^7.8.0", "stylelint-config-standard-scss": "^17.0.0", "stylelint-order": "^8.1.1", "ts-node": "^10.9.2", "tsc-esm-fix": "^3.1.2", "typescript": "^5.9.3", - "url-loader": "^4.1.1", - "yargs": "^18.0.0" + "webpack": "^5.109.2", + "yargs": "^18.1.0" }, "peerDependencies": { "@blueprintjs/core": ">=6", @@ -180,21 +178,11 @@ }, "resolutions": { "**/@blueprintjs/core": "6.8.1", - "node-sass-package-importer/**/postcss": "^8.5.18", - "stylelint-order/**/postcss": "^8.5.12", - "stylelint/**/postcss": "^8.5.18", "hast-util-from-parse5": "8.0.0", "**/lodash": "^4.18.1", "**/minimatch": "^3.1.4", - "**/serialize-javascript": "^7.0.5", - "**/ws": "^8.21.0", - "babel-jest/**/js-yaml": "^3.15.1", - "stylelint/**/js-yaml": "^4.3.1", - "@eslint/eslintrc/**/js-yaml": "^4.3.1", - "**/nanoid": "^3.3.18", - "**/fast-uri": "^3.1.3", - "sass/**/immutable": "^5.1.8", - "**/brace-expansion": "^1.1.18" + "**/postcss": "^8.5.18", + "**/serialize-javascript": "^7.0.5" }, "husky": { "hooks": { @@ -225,7 +213,6 @@ "src/**/*.{js,jsx,ts,tsx}", "!src/**/*.d.ts" ], - "resolver": "jest-pnp-resolver", "setupFiles": [ "react-app-polyfill/jsdom" ], diff --git a/scripts/compile-sass.ts b/scripts/compile-sass.ts index f3288bf6..355fea1f 100644 --- a/scripts/compile-sass.ts +++ b/scripts/compile-sass.ts @@ -1,20 +1,63 @@ +import * as fs from "fs"; import tildeImporter from "node-sass-package-importer"; +import * as path from "path"; import * as sass from "sass"; import yargs from "yargs"; import sassRenderSyncConfig from "./sassConfig"; -import { silenceDeprecations } from "./sassDeprecationConfig" +import { silenceDeprecations } from "./sassDeprecationConfig"; const args = yargs(process.argv.slice(2)).argv as any; +/** + * Returns the directory a package was installed into, or `undefined` if it cannot be found. + * The `node_modules` chain of the resolver is walked directly instead of resolving the + * `package.json` of the package, because a package is free to hide that file behind its + * `exports` map, which would let `require.resolve()` fail with `ERR_PACKAGE_PATH_NOT_EXPORTED`. + * Symbolic links are resolved, so that the real location is used, e.g. the store directory of pnpm. + */ +const packageDirectory = (packageName: string) => { + for (const searchPath of require.resolve.paths(packageName) ?? []) { + const candidate = path.join(searchPath, ...packageName.split("/")); + if (fs.existsSync(path.join(candidate, "package.json"))) return fs.realpathSync(candidate); + } + return undefined; +}; + +/** + * Returns the dependency root a package was installed into, i.e. the `node_modules` directory + * holding it. The number of levels to climb is taken from the package name, this way scoped + * (`@scope/name`) and plain (`name`) packages are both handled correctly. + */ +const dependencyRoot = (packageName: string) => { + const directory = packageDirectory(packageName); + return directory === undefined ? undefined : path.resolve(directory, ...packageName.split("/").map(() => "..")); +}; + +// Imports without tilde syntax are not handled by the importer below, sass resolves them relative +// to the importing file and via these include paths. `@blueprintjs/core` and `@carbon/react` both +// use them internally to reach their sibling packages, e.g. `@blueprintjs/colors` or +// `@carbon/styles`. Their dependency roots are looked up separately because the packages do not +// need to share one, e.g. if only one of them is hoisted into a parent workspace. The local +// `node_modules` is kept as a fallback, it is also used alone if a dependency root cannot be +// determined. All paths are absolute, so that duplicates of the same directory can be removed. +const includePaths = [ + ...new Set( + [dependencyRoot("@blueprintjs/core"), dependencyRoot("@carbon/react"), path.resolve("node_modules")].filter( + (includePath): includePath is string => includePath !== undefined, + ), + ), +]; + const styles = sass.renderSync({ importer: tildeImporter(), ...sassRenderSyncConfig, - silenceDeprecations: silenceDeprecations as sass.DeprecationOrId[], + silenceDeprecations: [...silenceDeprecations, "legacy-js-api"] as sass.DeprecationOrId[], file: "src/index.scss", - includePaths: ["node_modules"], // Carbon does not use tilde import syntax + includePaths, }); if (args.outputCss) { + // eslint-disable-next-line no-console console.log(styles.css.toString()); } diff --git a/scripts/fix-esm-dist b/scripts/fix-esm-dist index 61006373..ef7c3043 100755 --- a/scripts/fix-esm-dist +++ b/scripts/fix-esm-dist @@ -1,4 +1,9 @@ +#!/bin/sh +# abort as soon as one of the steps fails, otherwise a broken ESM output would be +# reported as a successful build because the exit code of the last command wins +set -e yarn tsc-esm-fix --target='dist/esm' +node ./scripts/fix-esm-imports.mjs dist/esm cat >dist/esm/package.json < + !specifier.startsWith(".") && !specifier.startsWith("/") && !/^\w[\w+.-]*:/.test(specifier); + +const collectFiles = (directory) => + fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) return collectFiles(entryPath); + return entry.name.endsWith(".js") ? [entryPath] : []; + }); + +const resolvesToFile = (specifier) => { + let resolved; + try { + resolved = import.meta.resolve(specifier); + } catch (error) { + // only a failed resolution is an expected answer, everything else (a broken resolver, a + // permission problem, …) has to abort the build instead of silently repairing nothing + if (unresolvableCodes.has(error?.code)) return false; + throw error; + } + return fs.existsSync(fileURLToPath(resolved)); +}; + +const replacements = new Map(); + +/** @returns the specifier that should be used instead, or `undefined` to keep it as it is */ +const repairedSpecifier = (specifier) => { + if (!replacements.has(specifier)) { + const withoutExtension = specifier.replace(/\.js$/, ""); + const repairable = + isBareSpecifier(specifier) && + specifier !== withoutExtension && + !resolvesToFile(specifier) && + resolvesToFile(withoutExtension); + replacements.set(specifier, repairable ? withoutExtension : undefined); + } + return replacements.get(specifier); +}; + +/** @returns the module references of the given source, the last one in the file first */ +const moduleReferences = (contents) => + [...ts.preProcessFile(contents, true, true).importedFiles].sort((one, other) => other.pos - one.pos); + +let repaired = 0; + +for (const file of collectFiles(target)) { + const contents = fs.readFileSync(file, "utf8"); + let fixedContents = contents; + // the references are replaced from the end of the file to its beginning, this way the + // positions of all not yet handled ones stay valid + for (const reference of moduleReferences(contents)) { + const specifier = reference.fileName; + const replacement = repairedSpecifier(specifier); + if (replacement === undefined) continue; + // `pos` addresses the quote opening the reference, its text starts behind it + const start = reference.pos + 1; + if (fixedContents.slice(start, start + specifier.length) !== specifier) { + throw new Error( + `fix-esm-imports: cannot repair "${specifier}" in ${file}, the source at position ${start} does not match the parsed reference`, + ); + } + fixedContents = fixedContents.slice(0, start) + replacement + fixedContents.slice(start + specifier.length); + } + if (fixedContents !== contents) { + fs.writeFileSync(file, fixedContents); + repaired += 1; + } +} + +for (const [specifier, replacement] of replacements) { + if (replacement !== undefined) { + console.log(`fix-esm-imports: "${specifier}" -> "${replacement}"`); + } +} +console.log(`fix-esm-imports: repaired imports in ${repaired} file(s) of ${target}`); diff --git a/scripts/sassDeprecationConfig.js b/scripts/sassDeprecationConfig.js index 888046ce..a286bfc3 100644 --- a/scripts/sassDeprecationConfig.js +++ b/scripts/sassDeprecationConfig.js @@ -1,3 +1,3 @@ module.exports = { - silenceDeprecations: ["import", "legacy-js-api"], -} + silenceDeprecations: ["import"], +}; diff --git a/src/_bom-workaround.scss b/src/_bom-workaround.scss new file mode 100644 index 00000000..bb47a3c1 --- /dev/null +++ b/src/_bom-workaround.scss @@ -0,0 +1,28 @@ +.bom-problem-workaround-dummy-rule { + /** + * This is a workaround to prevent the loss of the first rule of our compressed CSS. + * + * Sass emits a BOM instead of a `@charset` rule when it creates a compressed + * stylesheet that contains non-ASCII characters. + * Postcss preserves that BOM since v8.5.24, before that it silently dropped it. + * Css-loader does not remove it, although it inlines the CSS into a JS string that + * style-loader writes into a `