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
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@
},
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build && npm run build:types && npm run build:shims",
"build": "tsc --noEmit && vite build && npm run build:types && npm run build:shims && npm run test:exports",
"build:types": "tsc -p tsconfig.dts.json",
"build:shims": "node scripts/generate-entry-shims.mjs",
"test:exports": "node scripts/check-package-exports.mjs",
"preview": "vite preview",
"test": "vitest",
"test:unit": "vitest --project unit",
Expand Down
146 changes: 146 additions & 0 deletions scripts/check-package-exports.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
/*
* Verify, against the built `dist/`, that every published bare-directory
* import — `<pkg>`, `<pkg>/components/<name>`, `<pkg>/utilities/<name>` —
* resolves through the package `exports` map and is complete, for all three
* consumer shapes:
*
* - types: TypeScript (`moduleResolution: bundler`, as in our tsconfig)
* resolves the subpath under both the `import` and `require` conditions
* and finds value exports;
* - ESM: `import()` succeeds and provides every value export the types declare;
* - CJS: `require()` succeeds and provides every value export the types declare.
*
* The subpath list comes from the source tree (every `<name>/index.*`), not
* from `dist/`, so a component the build fails to emit is reported rather than
* silently skipped. Imports go through the package's own name (self-reference),
* so Node and TypeScript apply the real `exports` map, as a consumer's would.
*
* Regression check for alpha.4, where `components/color-picker`, `filter` and
* `tree` had types but no JS: their re-export-only `index` was not emitted, so
* no bare-directory shim was generated for them.
*
* Run after a full build: `npm run build` (which runs this as its last step).
*/
import { readdirSync, readFileSync, existsSync } from 'fs';
import { createRequire } from 'module';
import { join } from 'path';
import { fileURLToPath } from 'url';
import ts from 'typescript';

const root = fileURLToPath(new URL('..', import.meta.url));
const { name: pkg } = JSON.parse(
readFileSync(join(root, 'package.json'), 'utf8')
);
const require = createRequire(import.meta.url);

const INDEX_RE = /^index\.(js|jsx|ts|tsx)$/;
const hasIndex = (dir) =>
existsSync(dir) && readdirSync(dir).some((file) => INDEX_RE.test(file));

const subpaths = [pkg];
for (const sub of ['components', 'utilities']) {
for (const entry of readdirSync(join(root, sub), { withFileTypes: true })) {
if (entry.isDirectory() && hasIndex(join(root, sub, entry.name))) {
subpaths.push(`${pkg}/${sub}/${entry.name}`);
}
}
}

const failures = [];
const fail = (spec, message) => failures.push(`${spec}: ${message}`);

// --- Types -----------------------------------------------------------------

const compilerOptions = {
module: ts.ModuleKind.Preserve,
moduleResolution: ts.ModuleResolutionKind.Bundler,
jsx: ts.JsxEmit.ReactJSX,
strict: true,
skipLibCheck: true,
noEmit: true,
};
// Any file inside the package scope works as the importer for self-reference.
const importer = join(root, 'scripts', 'check-package-exports.ts');
const resolveTypes = (spec, mode) =>
ts.resolveModuleName(
spec,
importer,
compilerOptions,
ts.sys,
undefined,
undefined,
mode
).resolvedModule?.resolvedFileName;

/** @type {Map<string, string>} subpath -> resolved `.d.ts` (import condition) */
const typeFiles = new Map();
for (const spec of subpaths) {
const esm = resolveTypes(spec, ts.ModuleKind.ESNext);
const cjs = resolveTypes(spec, ts.ModuleKind.CommonJS);
if (!esm) fail(spec, 'types do not resolve under the "import" condition');
if (!cjs) fail(spec, 'types do not resolve under the "require" condition');
if (esm) typeFiles.set(spec, esm);
}

const program = ts.createProgram([...typeFiles.values()], compilerOptions);
const checker = program.getTypeChecker();

/** @type {Map<string, string[]>} subpath -> value (non-type-only) export names */
const declaredValues = new Map();
for (const [spec, file] of typeFiles) {
const moduleSymbol = checker.getSymbolAtLocation(program.getSourceFile(file));
const names = (moduleSymbol ? checker.getExportsOfModule(moduleSymbol) : [])
.filter((symbol) => {
const target =
symbol.flags & ts.SymbolFlags.Alias
? checker.getAliasedSymbol(symbol)
: symbol;
// A re-export whose target doesn't resolve aliases TypeScript's
// declaration-less `unknown` symbol, which carries a value flag.
return target.declarations?.length && target.flags & ts.SymbolFlags.Value;
})
.map((symbol) => symbol.getName());
if (names.length === 0) {
fail(spec, `types (${file.slice(root.length)}) declare no value exports`);
}
declaredValues.set(spec, names);
}

// --- Runtime ---------------------------------------------------------------

const checkRuntime = (spec, kind, mod) => {
const missing = (declaredValues.get(spec) ?? []).filter(
(name) => mod[name] === undefined
);
if (missing.length) {
fail(
spec,
`${kind} is missing exports declared in its types: ${missing.join(', ')}`
);
}
};

for (const spec of subpaths) {
try {
checkRuntime(spec, 'ESM', await import(spec));
} catch (error) {
fail(spec, `ESM import failed: ${error.message.split('\n')[0]}`);
}
try {
checkRuntime(spec, 'CJS', require(spec));
} catch (error) {
fail(spec, `CJS require failed: ${error.message.split('\n')[0]}`);
}
}

/* eslint-disable no-console */
if (failures.length) {
console.error(
`check-package-exports: ${failures.length} problem(s) across ${subpaths.length} entry points:\n` +
failures.map((line) => ` - ${line}`).join('\n')
);
process.exit(1);
}
console.log(
`check-package-exports: ${subpaths.length} entry points resolve for types, ESM and CJS.`
);
29 changes: 23 additions & 6 deletions scripts/generate-entry-shims.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,13 @@
* `accordion/index.js` that simply re-exports the index. One star pattern then
* resolves every historical import shape.
*/
import { readdirSync, statSync, existsSync, writeFileSync } from 'fs';
import {
readdirSync,
readFileSync,
statSync,
existsSync,
writeFileSync,
} from 'fs';
import { join, relative } from 'path';
import { fileURLToPath } from 'url';

Expand All @@ -25,13 +31,21 @@ const OUTPUTS = [
{ dir: 'dist/types', ext: 'd.ts', kind: 'dts' },
];

const shimBody = (kind, indexSpecifier) => {
// `export *` never forwards `default`, so it is re-exported explicitly — but only
// when the index has one (e.g. `utilities/hooks` is named-exports only), since
// re-exporting a missing `default` is a link error in ESM.
const DEFAULT_EXPORT_RE =
/\bexport\s+default\b|\bas\s+default\b|\bexport\s*\{\s*default\b/;

const shimBody = (kind, indexSpecifier, indexSource) => {
if (kind === 'cjs') {
return `module.exports = require('${indexSpecifier}.cjs');\n`;
}
const spec =
kind === 'dts' ? indexSpecifier : `${indexSpecifier}.js`;
return `export * from '${spec}';\nexport { default } from '${spec}';\n`;
const spec = kind === 'dts' ? indexSpecifier : `${indexSpecifier}.js`;
const body = `export * from '${spec}';\n`;
return DEFAULT_EXPORT_RE.test(indexSource)
? `${body}export { default } from '${spec}';\n`
: body;
};

let created = 0;
Expand All @@ -48,7 +62,10 @@ const walk = (outDir, ext, kind, absDir) => {
const shimPath = `${abs}.${ext}`;
if (!existsSync(shimPath)) {
const name = entry;
writeFileSync(shimPath, shimBody(kind, `./${name}/index`));
writeFileSync(
shimPath,
shimBody(kind, `./${name}/index`, readFileSync(indexFile, 'utf8'))
);
created += 1;
}
}
Expand Down
29 changes: 24 additions & 5 deletions vite.config.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,25 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { globSync } from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';

const __dirname = path.dirname(fileURLToPath(import.meta.url));

// Every `components/<name>/index` and `utilities/<name>/index` is a published
// bare-directory entry (`.../components/tree`). They must be explicit entries:
// an index that only re-exports (`export { default } from './tree'`) is not
// emitted under `preserveModules` when it is merely reachable from the barrel,
// because Rollup binds the barrel's import straight through to `./tree`. With
// no built `index.js` the shim script has nothing to point at and the import
// fails. Entries also pull in components the barrel doesn't export (`grid`).
const directoryEntries = globSync(
'{components,utilities}/*/index.{js,jsx,ts,tsx}',
{
cwd: __dirname,
}
).map((file) => path.resolve(__dirname, file));

// Externalize React and any bare (node_modules) dependency — a component library
// should not bundle its deps. Relative/aliased source is kept in the build.
const external = (id: string) =>
Expand Down Expand Up @@ -38,11 +53,15 @@ export default defineConfig({
transformMixedEsModules: true,
},
lib: {
// The barrel is the graph root; `preserveModules` (below) then emits every
// reachable source module as its own file, mirroring the components/ tree,
// so consumers can deep-import `@salesforce/design-system-react/components/
// accordion` (and `.../accordion/panel`, etc.) as they always could.
entry: path.resolve(__dirname, 'components/index.js'),
// The barrel and every directory index are the graph roots;
// `preserveModules` (below) then emits every reachable source module as
// its own file, mirroring the components/ tree, so consumers can
// deep-import `@salesforce/design-system-react/components/accordion`
// (and `.../accordion/panel`, etc.) as they always could.
entry: [
path.resolve(__dirname, 'components/index.js'),
...directoryEntries,
],
formats: ['es', 'cjs'],
},
rollupOptions: {
Expand Down
Loading