Skip to content
Draft
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
8 changes: 8 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ jobs:
run: pnpm build:react
- run: pnpm test

packaging:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: ./.github/actions/dependencies
- name: Validate packaging
run: pnpm --filter @deque/cauldron-react verify:packaging

screenshots:
if: ${{ !contains(github.event.pull_request.labels.*.name, 'update-screenshots') }}
runs-on: ubuntu-latest
Expand Down
10 changes: 8 additions & 2 deletions .storybook/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,15 @@ const config: StorybookConfig = {
config.resolve = config.resolve || {};
config.resolve.alias = {
...(config.resolve.alias || {}),
'@deque/cauldron-react': path.resolve(
// Consume the built ESM output. `$` matches the bare barrel import only,
// so the `/cauldron.css` short-form falls through to its own alias.
'@deque/cauldron-react$': path.resolve(
process.cwd(),
'packages/react/lib'
'packages/react/lib/esm/index.js'
),
'@deque/cauldron-react/cauldron.css': path.resolve(
process.cwd(),
'packages/react/lib/cauldron.css'
),
react: path.resolve(process.cwd(), 'node_modules/react'),
'react-dom': path.resolve(process.cwd(), 'node_modules/react-dom')
Expand Down
2 changes: 1 addition & 1 deletion docs/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ import '@fontsource/pt-mono';
import '../packages/styles';
import '@deque/cauldron-react/cauldron.css';
import './index.css';
import { useThemeContext } from '../packages/react/lib';
import { useThemeContext } from '@deque/cauldron-react';

const CAULDRON_THEME_STORAGE_KEY = 'cauldron_theme';

Expand Down
26 changes: 23 additions & 3 deletions packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,36 @@
"publishConfig": {
"access": "public"
},
"main": "lib/index.js",
"main": "lib/cjs/index.js",
"module": "lib/esm/index.js",
"style": "lib/cauldron.css",
"types": "lib/index.d.ts",
"types": "lib/cjs/index.d.ts",
"exports": {
".": {
"import": {
"types": "./lib/esm/index.d.ts",
"default": "./lib/esm/index.js"
},
"require": {
"types": "./lib/cjs/index.d.ts",
"default": "./lib/cjs/index.js"
}
},
"./cauldron.css": "./lib/cauldron.css",
"./lib/cauldron.css": "./lib/cauldron.css",
"./package.json": "./package.json"
},
"sideEffects": false,
"files": [
"lib/"
],
"scripts": {
"build": "pnpm build:lib && pnpm build:css",
"prebuild:lib": "node scripts/buildIconTypes.js",
"build:lib": "rollup -c",
"build:lib": "rimraf lib && rollup -c && node scripts/addEsmDeclarationExtensions.js",
"build:css": "postcss --output=lib/cauldron.css src/index.css",
"typecheck": "tsc --noEmit --skipLibCheck",
"verify:packaging": "pnpm build && node scripts/verifyPackaging.js",
"dev": "concurrently 'pnpm build:css --watch' 'rollup -c --watch'",
"prepublishOnly": "NODE_ENV=production pnpm build",
"test": "jest --maxWorkers=1 --coverage",
Expand All @@ -38,6 +56,7 @@
"tslib": "^2.4.0"
},
"devDependencies": {
"@arethetypeswrong/cli": "^0.18.4",
"@babel/core": "^7.29.7",
"@babel/plugin-proposal-export-default-from": "^7.29.7",
"@babel/preset-env": "^7.29.7",
Expand Down Expand Up @@ -73,6 +92,7 @@
"postcss-import": "^16.1.1",
"postcss-loader": "^8.2.1",
"prop-types": "^15.8.1",
"publint": "^0.3.21",
"react": "^19",
"react-dom": "^19",
"rollup": "^2.23.0",
Expand Down
159 changes: 103 additions & 56 deletions packages/react/rollup.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,62 +4,109 @@ import pkg from './package.json';
import svgr from '@svgr/rollup';
import dynamicImportVar from '@rollup/plugin-dynamic-import-vars';

export default {
input: 'src/index.ts',
external: [
...Object.keys(pkg.dependencies),
...Object.keys(pkg.peerDependencies),
// Note: We directly import only the specific language syntax needed
// directly in the Code component. This ensures it is still treated as
// an external dependency since it won't match the dependencies or
// peerDependencies when pulled from package.json.
/^react-syntax-highlighter/
],
output: {
dir: 'lib',
format: 'cjs',
exports: 'auto',
chunkFileNames: '[name].js'
},
plugins: [
typescript({
tsconfig: './tsconfig.json',
exclude: [
'**.test.ts',
'**.test.tsx',
'**.figma.tsx',
'**.stories.tsx',
'./src/setupTests.ts',
'./src/axe.ts'
]
}),
commonjs(),
svgr({
svgoConfig: {
plugins: [
{
name: 'preset-default',
params: {
overrides: {
removeViewBox: false
}
}
},
{
name: 'removeDimensions',
params: {
active: true
}
},
{
name: 'addAttributesToSVGElement',
params: {
attributes: [{ height: 24 }, { width: 24 }]
}
const external = [
...Object.keys(pkg.dependencies),
...Object.keys(pkg.peerDependencies),
// Note: We directly import only the specific language syntax needed
// directly in the Code component. This ensures it is still treated as
// an external dependency since it won't match the dependencies or
// peerDependencies when pulled from package.json.
/^react-syntax-highlighter/
];

const svgrOptions = {
svgoConfig: {
plugins: [
{
name: 'preset-default',
params: {
overrides: {
removeViewBox: false
}
]
}
},
{
name: 'removeDimensions',
params: {
active: true
}
},
{
name: 'addAttributesToSVGElement',
params: {
attributes: [{ height: 24 }, { width: 24 }]
}
}
}),
dynamicImportVar()
]
]
}
};

/**
* Writes a `package.json` into an output directory whose only job is to mark
* the module kind of the emitted `.js` files (e.g. `{"type": "module"}` for the
* ESM output). This lets us keep plain `.js` extensions in both builds while
* Node still resolves each one as the correct module system.
*/
function emitTypeMarker(type) {
return {
name: 'emit-type-marker',
generateBundle() {
this.emitFile({
type: 'asset',
fileName: 'package.json',
source: `${JSON.stringify({ type }, null, 2)}\n`
});
}
};
}

/**
* Each build compiles from source independently so that its `.d.ts` files land
* beside its `.js` files and are interpreted with the matching module kind —
* this keeps `@arethetypeswrong/cli` happy (no types/runtime "masquerading").
*/
function build({ format, dir, type }) {
return {
input: 'src/index.ts',
external,
output: {
dir,
format,
// The public entry is the named-export barrel; per-module files expose
// their component as the `default` export (with `__esModule`) for interop.
exports: 'named',
// Preserve the source module graph (one output file per source module,
// mirroring src/) instead of bundling everything into index.js. Combined
// with "sideEffects": false, this is what lets a consumer's bundler drop
// unused components — e.g. a Button-only import excludes Code and its
// react-syntax-highlighter dependency.
preserveModules: true,
preserveModulesRoot: 'src',
entryFileNames: '[name].js'
},
plugins: [
typescript({
tsconfig: './tsconfig.json',
declaration: true,
declarationDir: dir,
exclude: [
'**.test.ts',
'**.test.tsx',
'**.figma.tsx',
'**.stories.tsx',
'./src/setupTests.ts',
'./src/axe.ts'
]
}),
commonjs(),
svgr(svgrOptions),
dynamicImportVar(),
emitTypeMarker(type)
]
};
}

export default [
build({ format: 'cjs', dir: 'lib/cjs', type: 'commonjs' }),
build({ format: 'es', dir: 'lib/esm', type: 'module' })
];
81 changes: 81 additions & 0 deletions packages/react/scripts/addEsmDeclarationExtensions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/**
* Adds explicit file extensions to relative imports in the ESM build's `.d.ts`
* files.
*
* TypeScript emits declarations using the source's classic `moduleResolution:
* node`, so relative specifiers are extensionless (`from './components/Button'`).
* The ESM output carries a `{"type":"module"}` marker, and under
* `moduleResolution: node16`/`nodenext` a consumer resolves those declarations
* with ESM rules — where extensionless relative imports are an error. The
* runtime `.js` already has correct extensions (Rollup adds them); this brings
* the `.d.ts` in line so types resolve cleanly for node16-from-ESM consumers.
*
* Only `lib/esm` is processed: the `lib/cjs` declarations are resolved with
* CommonJS rules, which infer the extension.
*/
const fs = require('node:fs');
const path = require('node:path');

const esmDir = path.join(__dirname, '..', 'lib', 'esm');

// `from '<spec>'`, `import('<spec>')`, and `export ... from '<spec>'` all match.
const SPECIFIER = /(\bfrom\s*|\bimport\s*\(\s*)(['"])(\.\.?\/[^'"]*)\2/g;

/** Resolve an extensionless relative specifier to its emitted `.js` path. */
function withExtension(fromFileDir, specifier) {
if (/\.(js|mjs|cjs|json|css)$/.test(specifier)) {
return null; // already explicit
}
const resolved = path.resolve(fromFileDir, specifier);
if (fs.existsSync(`${resolved}.d.ts`)) {
return `${specifier}.js`;
}
if (fs.existsSync(path.join(resolved, 'index.d.ts'))) {
return `${specifier}/index.js`;
}
return null; // external or unresolved — leave untouched
}

function processFile(file) {
const source = fs.readFileSync(file, 'utf8');
const dir = path.dirname(file);
let changed = false;

const output = source.replace(
SPECIFIER,
(match, prefix, quote, specifier) => {
const rewritten = withExtension(dir, specifier);
if (!rewritten) {
return match;
}
changed = true;
return `${prefix}${quote}${rewritten}${quote}`;
}
);

if (changed) {
fs.writeFileSync(file, output);
}
return changed;
}

function walk(dir) {
let count = 0;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
count += walk(full);
} else if (entry.name.endsWith('.d.ts')) {
count += processFile(full) ? 1 : 0;
}
}
return count;
}

if (!fs.existsSync(esmDir)) {
console.error(`Missing ESM build at ${esmDir}. Run the rollup build first.`);
process.exit(1);
}

const changed = walk(esmDir);
console.log('Added ESM declaration extensions in %d file(s)', changed);
27 changes: 27 additions & 0 deletions packages/react/scripts/packaging-smoke/dual-resolution.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Dual-resolution wiring check.
//
// With the `exports` map, `import` resolves to the ESM build and `require` to
// the CJS build. They are therefore DISTINCT copies — the accepted, documented
// dual-package tradeoff (a runtime mixing both loads two copies; see the PR /
// changelog). This asserts both conditions resolve and expose the full public
// API, so the exports map is wired correctly for ESM and CJS consumers alike.
import assert from 'node:assert';
import { createRequire } from 'node:module';
import * as esm from '@deque/cauldron-react';

const require = createRequire(import.meta.url);
const cjs = require('@deque/cauldron-react');

for (const [label, api] of [
['import (ESM)', esm],
['require (CJS)', cjs]
]) {
assert(api.Button, `${label}: expected \`Button\` export`);
assert(api.ThemeProvider, `${label}: expected \`ThemeProvider\` export`);
assert(api.ThemeContext, `${label}: expected \`ThemeContext\` export`);
}

console.log(
'dual-resolution OK: import→ESM and require→CJS both expose the API ' +
`(distinct copies: ${esm.ThemeContext !== cjs.ThemeContext})`
);
15 changes: 15 additions & 0 deletions packages/react/scripts/packaging-smoke/smoke.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Smoke test: the packed tarball resolves under CommonJS `require`.
// Runs inside a throwaway consumer where the tarball is installed (not the
// workspace symlink), so this exercises the real published resolution.
const assert = require('node:assert');

const cauldron = require('@deque/cauldron-react');

assert(cauldron.Button, 'require: expected `Button` named export');
assert(cauldron.Modal, 'require: expected `Modal` named export');
assert(
typeof cauldron.Button === 'object' || typeof cauldron.Button === 'function',
'require: `Button` is not a renderable component'
);

console.log('require("@deque/cauldron-react") OK');
Loading
Loading