Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
lts/krypton
50 changes: 31 additions & 19 deletions .storybook/main.js
Original file line number Diff line number Diff line change
@@ -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");

Expand All @@ -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: {
Expand Down Expand Up @@ -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
Expand All @@ -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 (
Expand All @@ -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;
},
};
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p

- 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

Expand All @@ -23,6 +25,13 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p
- 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 `<Tab />` 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 `<CodeEditor />` 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

Expand Down
13 changes: 7 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -83,6 +83,7 @@
"@codemirror/legacy-modes": "^6.5.3",
"@mavrin/remark-typograf": "^2.2.0",
"@xyflow/react": "^12.11.5",
"assert": "^2.1.0",
"classnames": "^2.5.1",
"codemirror": "^6.0.2",
"color": "^5.0.3",
Expand All @@ -106,6 +107,7 @@
"remark-parse": "^11.0.0",
"reset-css": "^5.0.2",
"unified": "^11.0.5",
"util": "^0.12.5",
"wicg-inert": "^3.1.3"
},
"devDependencies": {
Expand All @@ -121,7 +123,6 @@
"@storybook/addon-links": "^10.5.10",
"@storybook/addon-webpack5-compiler-babel": "^4.0.1",
"@storybook/cli": "^10.5.10",
"@storybook/preset-scss": "^1.0.3",
"@storybook/react": "^10.5.10",
"@storybook/react-webpack5": "^10.5.10",
"@testing-library/dom": "^10.4.1",
Expand All @@ -139,6 +140,7 @@
"@typescript-eslint/parser": "^8.68.0",
"babel-jest": "^30.4.1",
"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",
Expand All @@ -148,7 +150,6 @@
"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",
Expand All @@ -158,16 +159,17 @@
"regenerator-runtime": "^0.14.1",
"rimraf": "^6.1.3",
"sass": "^1.103.1",
"sass-loader": "^16.0.8",
"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",
"webpack": "^5.109.2",
"yargs": "^18.1.0"
},
"peerDependencies": {
Expand Down Expand Up @@ -211,7 +213,6 @@
"src/**/*.{js,jsx,ts,tsx}",
"!src/**/*.d.ts"
],
"resolver": "jest-pnp-resolver",
"setupFiles": [
"react-app-polyfill/jsdom"
],
Expand Down
46 changes: 44 additions & 2 deletions scripts/compile-sass.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
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";

Expand All @@ -7,12 +9,52 @@ 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) {
Expand Down
5 changes: 5 additions & 0 deletions scripts/fix-esm-dist
Original file line number Diff line number Diff line change
@@ -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 <<!EOF
{
"type": "module"
Expand Down
128 changes: 128 additions & 0 deletions scripts/fix-esm-imports.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/* eslint-disable no-console, no-undef -- build script running in node, not in the browser */
/**
* Repairs module references that `tsc-esm-fix` rewrote into unresolvable ones.
*
* `tsc-esm-fix` appends `.js` to every module reference it can map to a file inside
* `node_modules`, including bare imports into packages whose `exports` map only exposes
* the extension-less subpath. `@codemirror/legacy-modes` for example declares
* `"./mode/*": { "import": "./mode/*.js" }`, so the rewritten `@codemirror/legacy-modes/mode/jinja2.js`
* expands to `mode/jinja2.js.js` and neither Node nor a bundler can resolve it.
*
* Every bare import in the ESM output is checked here: when it does not point to an
* existing file but its extension-less form does, the added extension is removed again.
*
* The references are collected by the TypeScript scanner instead of a regular expression, so
* that all import forms are covered (including side effect only imports like `import "…";`)
* and no comment, string literal or regular expression that happens to contain something like
* `from "…"` is mistaken for a module reference.
*/

import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import ts from "typescript";

const target = process.argv[2] ?? "dist/esm";

// `import.meta.resolve` only returns a string synchronously since node 18.19/20.0, before that it
// was hidden behind `--experimental-import-meta-resolve` and answered with a promise. Without this
// check every lookup below would just fail, no reference would be repaired and the build would
// still be reported as successful while shipping unresolvable imports.
if (typeof import.meta.resolve !== "function") {
throw new Error(
`fix-esm-imports: this script needs a synchronous "import.meta.resolve", available since node 18.19.0, but runs on ${process.version}`,
);
}

/** error codes of node's module resolution that really mean "there is nothing to import here" */
const unresolvableCodes = new Set([
"ERR_MODULE_NOT_FOUND",
"ERR_PACKAGE_PATH_NOT_EXPORTED",
"ERR_PACKAGE_IMPORT_NOT_DEFINED",
"ERR_UNSUPPORTED_DIR_IMPORT",
"ERR_UNSUPPORTED_ESM_URL_SCHEME",
"ERR_INVALID_MODULE_SPECIFIER",
"ERR_INVALID_PACKAGE_CONFIG",
"ERR_INVALID_PACKAGE_TARGET",
]);

/**
* Only bare references can be checked here, relative ones are resolved against the output
* directory and references using a URL scheme (`node:`, `data:`, `http:`, …) never address a
* package sub path.
*/
const isBareSpecifier = (specifier) =>
!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}`);
2 changes: 1 addition & 1 deletion scripts/sassDeprecationConfig.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
module.exports = {
silenceDeprecations: ["import", "legacy-js-api"],
silenceDeprecations: ["import"],
};
Loading
Loading