@rtorcato/js-tooling 2.8.1 → 2.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/commands/doctor.js +124 -0
- package/dist/cli/commands/fix-targets.js +3 -0
- package/dist/cli/commands/fix.js +83 -5
- package/dist/cli/commands/setup-presets.js +8 -1
- package/dist/cli/commands/setup.js +11 -0
- package/dist/cli/generators/git.js +28 -0
- package/dist/cli/generators/index.js +14 -0
- package/dist/cli/generators/package-json.js +62 -2
- package/dist/cli/generators/treeshake.js +113 -0
- package/package.json +15 -3
- package/tooling/tests/exports-resolution.d.mts +14 -0
- package/tooling/tests/exports-resolution.mjs +68 -0
- package/tooling/tests/ssr-safety.d.mts +14 -0
- package/tooling/tests/ssr-safety.mjs +53 -0
|
@@ -231,6 +231,100 @@ async function checkHusky(dir, pkg) {
|
|
|
231
231
|
hint: 'Run `pnpm add -D husky && pnpm exec husky init` to enable git hooks',
|
|
232
232
|
};
|
|
233
233
|
}
|
|
234
|
+
async function checkHuskyPrePush(dir, pkg) {
|
|
235
|
+
const huskyDir = await fs.pathExists(path.join(dir, '.husky'));
|
|
236
|
+
if (!huskyDir) {
|
|
237
|
+
// If husky isn't in use, pre-push is not relevant
|
|
238
|
+
return {
|
|
239
|
+
check: 'Husky pre-push',
|
|
240
|
+
status: 'optional-missing',
|
|
241
|
+
detail: 'husky not configured',
|
|
242
|
+
hint: 'Run `npx @rtorcato/js-tooling fix husky` to enable git hooks (includes pre-push)',
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
const hookPath = path.join(dir, '.husky', 'pre-push');
|
|
246
|
+
if (!(await fs.pathExists(hookPath))) {
|
|
247
|
+
return {
|
|
248
|
+
check: 'Husky pre-push',
|
|
249
|
+
status: 'optional-missing',
|
|
250
|
+
detail: 'no .husky/pre-push',
|
|
251
|
+
hint: 'Run `npx @rtorcato/js-tooling fix husky` to scaffold a pre-push hook that runs `pnpm verify`',
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
const contents = await fs.readFile(hookPath, 'utf-8');
|
|
255
|
+
if (/\bpnpm\s+verify\b/.test(contents)) {
|
|
256
|
+
return {
|
|
257
|
+
check: 'Husky pre-push',
|
|
258
|
+
status: 'ok',
|
|
259
|
+
detail: '.husky/pre-push runs `pnpm verify`',
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
// Pre-push exists but doesn't call pnpm verify
|
|
263
|
+
const scripts = pkg?.scripts ?? {};
|
|
264
|
+
if (!scripts.verify) {
|
|
265
|
+
return {
|
|
266
|
+
check: 'Husky pre-push',
|
|
267
|
+
status: 'drift',
|
|
268
|
+
detail: '.husky/pre-push exists but no `verify` script in package.json',
|
|
269
|
+
hint: 'Run `npx @rtorcato/js-tooling fix verify` to add a verify script, then `fix husky` to align the hook',
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
return {
|
|
273
|
+
check: 'Husky pre-push',
|
|
274
|
+
status: 'drift',
|
|
275
|
+
detail: '.husky/pre-push exists but does not call `pnpm verify`',
|
|
276
|
+
hint: 'Run `npx @rtorcato/js-tooling fix husky` to align the hook with `pnpm verify`',
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
async function checkVerifyScript(dir, pkg) {
|
|
280
|
+
if (!pkg) {
|
|
281
|
+
return {
|
|
282
|
+
check: 'verify script',
|
|
283
|
+
status: 'missing',
|
|
284
|
+
detail: 'no package.json',
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
const scripts = pkg.scripts ?? {};
|
|
288
|
+
const body = scripts.verify;
|
|
289
|
+
if (!body) {
|
|
290
|
+
return {
|
|
291
|
+
check: 'verify script',
|
|
292
|
+
status: 'optional-missing',
|
|
293
|
+
detail: 'no `verify` script in package.json',
|
|
294
|
+
hint: 'Run `npx @rtorcato/js-tooling fix verify` to add a unified `pnpm verify` script',
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
// Lenient: only flag drift when an enabled tool is clearly absent from the script body.
|
|
298
|
+
const deps = {
|
|
299
|
+
...(pkg.dependencies ?? {}),
|
|
300
|
+
...(pkg.devDependencies ?? {}),
|
|
301
|
+
};
|
|
302
|
+
const missing = [];
|
|
303
|
+
if (scripts.typecheck && !/\btypecheck\b/.test(body))
|
|
304
|
+
missing.push('typecheck');
|
|
305
|
+
if ((scripts.check || deps['@biomejs/biome']) && !/\b(check|biome|lint)\b/.test(body)) {
|
|
306
|
+
missing.push('lint/check');
|
|
307
|
+
}
|
|
308
|
+
if ((deps.vitest || scripts.test) && !/(vitest|jest|test:e2e|pnpm\s+test)/.test(body)) {
|
|
309
|
+
missing.push('tests');
|
|
310
|
+
}
|
|
311
|
+
const hasTreeshakeApp = await fs.pathExists(path.join(dir, 'apps', 'treeshake-check', 'check.mjs'));
|
|
312
|
+
if (hasTreeshakeApp && !/\btreeshake\b/.test(body))
|
|
313
|
+
missing.push('treeshake');
|
|
314
|
+
if (missing.length > 0) {
|
|
315
|
+
return {
|
|
316
|
+
check: 'verify script',
|
|
317
|
+
status: 'drift',
|
|
318
|
+
detail: `\`verify\` script is missing: ${missing.join(', ')}`,
|
|
319
|
+
hint: 'Run `npx @rtorcato/js-tooling fix verify` to regenerate the verify chain',
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
return {
|
|
323
|
+
check: 'verify script',
|
|
324
|
+
status: 'ok',
|
|
325
|
+
detail: `\`verify\` = ${body}`,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
234
328
|
const LINT_STAGED_FILES = [
|
|
235
329
|
'.lintstagedrc',
|
|
236
330
|
'.lintstagedrc.json',
|
|
@@ -485,6 +579,33 @@ async function checkCodeQL(dir) {
|
|
|
485
579
|
hint: 'Run `npx @rtorcato/js-tooling fix codeql` to scaffold CodeQL security scanning',
|
|
486
580
|
};
|
|
487
581
|
}
|
|
582
|
+
async function checkTreeshakeSetup(dir, pkg) {
|
|
583
|
+
const appCheckPath = path.join(dir, 'apps', 'treeshake-check', 'check.mjs');
|
|
584
|
+
if (await fs.pathExists(appCheckPath)) {
|
|
585
|
+
return {
|
|
586
|
+
check: 'Tree-shake check',
|
|
587
|
+
status: 'ok',
|
|
588
|
+
detail: 'apps/treeshake-check/check.mjs found',
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
// Only nudge libraries that actually claim tree-shaking via multi-subpath exports + sideEffects: false.
|
|
592
|
+
const exports = pkg?.exports ?? {};
|
|
593
|
+
const subpaths = Object.keys(exports).filter((k) => k !== '.' && k.startsWith('./') && !k.includes('*'));
|
|
594
|
+
const sideEffectsFree = pkg?.sideEffects === false;
|
|
595
|
+
if (subpaths.length < 2 || !sideEffectsFree) {
|
|
596
|
+
return {
|
|
597
|
+
check: 'Tree-shake check',
|
|
598
|
+
status: 'ok',
|
|
599
|
+
detail: 'not applicable (single-export or has side effects)',
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
return {
|
|
603
|
+
check: 'Tree-shake check',
|
|
604
|
+
status: 'optional-missing',
|
|
605
|
+
detail: `package exports ${subpaths.length} subpaths with sideEffects: false but no apps/treeshake-check/`,
|
|
606
|
+
hint: 'Run `npx @rtorcato/js-tooling fix treeshake-check` to scaffold an esbuild metafile assertion',
|
|
607
|
+
};
|
|
608
|
+
}
|
|
488
609
|
async function checkGitLabCI(dir) {
|
|
489
610
|
for (const candidate of ['.gitlab-ci.yml', '.gitlab-ci.yaml']) {
|
|
490
611
|
if (await fs.pathExists(path.join(dir, candidate))) {
|
|
@@ -516,6 +637,8 @@ export async function runDoctor(dir) {
|
|
|
516
637
|
}
|
|
517
638
|
results.push(await checkHusky(targetDir, pkg));
|
|
518
639
|
results.push(await checkLintStaged(targetDir, pkg));
|
|
640
|
+
results.push(await checkVerifyScript(targetDir, pkg));
|
|
641
|
+
results.push(await checkHuskyPrePush(targetDir, pkg));
|
|
519
642
|
results.push(await checkSemanticRelease(targetDir, pkg));
|
|
520
643
|
results.push(await checkKnip(targetDir, pkg));
|
|
521
644
|
results.push(await checkSizeLimit(targetDir, pkg));
|
|
@@ -523,6 +646,7 @@ export async function runDoctor(dir) {
|
|
|
523
646
|
results.push(await checkDependabot(targetDir));
|
|
524
647
|
results.push(await checkCodeQL(targetDir));
|
|
525
648
|
results.push(await checkGitLabCI(targetDir));
|
|
649
|
+
results.push(await checkTreeshakeSetup(targetDir, pkg));
|
|
526
650
|
return results;
|
|
527
651
|
}
|
|
528
652
|
const STATUS_ICONS = {
|
|
@@ -11,9 +11,12 @@ export const FIX_TARGETS = {
|
|
|
11
11
|
Commitlint: 'commitlint',
|
|
12
12
|
Husky: 'husky',
|
|
13
13
|
'lint-staged': 'husky',
|
|
14
|
+
'Husky pre-push': 'husky',
|
|
15
|
+
'verify script': 'verify',
|
|
14
16
|
'semantic-release': 'semantic-release',
|
|
15
17
|
knip: 'knip',
|
|
16
18
|
'size-limit': 'size-limit',
|
|
19
|
+
'Tree-shake check': 'treeshake-check',
|
|
17
20
|
'GitHub Actions': 'github-actions',
|
|
18
21
|
Dependabot: 'dependabot',
|
|
19
22
|
CodeQL: 'codeql',
|
package/dist/cli/commands/fix.js
CHANGED
|
@@ -3,10 +3,12 @@ import chalk from 'chalk';
|
|
|
3
3
|
import fs from 'fs-extra';
|
|
4
4
|
import inquirer from 'inquirer';
|
|
5
5
|
import { generateSemanticReleaseConfig } from '../generators/build.js';
|
|
6
|
-
import { generateCommitlintConfig, generateHuskyConfig } from '../generators/git.js';
|
|
6
|
+
import { generateCommitlintConfig, generateHuskyConfig, generatePrePushHook, } from '../generators/git.js';
|
|
7
7
|
import { generateGitHubActions } from '../generators/github-actions.js';
|
|
8
8
|
import { generateESLintConfig, generatePrettierConfig } from '../generators/linting.js';
|
|
9
9
|
import { ensureEnginesNode, generateEditorConfig, generateKnipConfig, generateNvmrc, generateSizeLimitConfig, } from '../generators/misc.js';
|
|
10
|
+
import { composeVerifyScriptFromPkg } from '../generators/package-json.js';
|
|
11
|
+
import { generateTreeshakeCheck, inferSubpathsFromExports } from '../generators/treeshake.js';
|
|
10
12
|
import { generateCodeQLWorkflow, generateDependabotConfig } from '../generators/security.js';
|
|
11
13
|
import { generateVitestConfig } from '../generators/testing.js';
|
|
12
14
|
import { copyPreset } from '../utils/copy-preset.js';
|
|
@@ -124,9 +126,9 @@ const FIXERS = [
|
|
|
124
126
|
},
|
|
125
127
|
{
|
|
126
128
|
target: 'husky',
|
|
127
|
-
description: 'Set up Husky + lint-staged',
|
|
128
|
-
appliesTo: ['Husky', 'lint-staged'],
|
|
129
|
-
outputs: ['.husky/pre-commit', 'package.json (lint-staged field)'],
|
|
129
|
+
description: 'Set up Husky + lint-staged (and a `pnpm verify` pre-push hook)',
|
|
130
|
+
appliesTo: ['Husky', 'lint-staged', 'Husky pre-push'],
|
|
131
|
+
outputs: ['.husky/pre-commit', '.husky/pre-push', 'package.json (lint-staged field)'],
|
|
130
132
|
riskLevel: 'safe-merge',
|
|
131
133
|
canFixDrift: true,
|
|
132
134
|
async run({ targetDir, pkg }) {
|
|
@@ -137,7 +139,46 @@ const FIXERS = [
|
|
|
137
139
|
const generated = updated['lint-staged'] ?? {};
|
|
138
140
|
updated['lint-staged'] = { ...generated, ...existingLintStaged };
|
|
139
141
|
await fs.writeJson(pkgPath, updated, { spaces: 2 });
|
|
140
|
-
|
|
142
|
+
const filesWritten = ['.husky/pre-commit', 'package.json'];
|
|
143
|
+
const scripts = updated.scripts ?? {};
|
|
144
|
+
if (scripts.verify) {
|
|
145
|
+
await generatePrePushHook(targetDir);
|
|
146
|
+
filesWritten.push('.husky/pre-push');
|
|
147
|
+
}
|
|
148
|
+
return { filesWritten };
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
target: 'verify',
|
|
153
|
+
description: 'Add a unified `verify` script (typecheck && lint && tests) to package.json',
|
|
154
|
+
appliesTo: ['verify script'],
|
|
155
|
+
outputs: ['package.json (scripts.verify)'],
|
|
156
|
+
riskLevel: 'safe-merge',
|
|
157
|
+
canFixDrift: true,
|
|
158
|
+
async run({ targetDir, pkg }) {
|
|
159
|
+
const pkgPath = path.join(targetDir, 'package.json');
|
|
160
|
+
if (!pkg) {
|
|
161
|
+
console.log(chalk.yellow(' no package.json found — skipping'));
|
|
162
|
+
return { filesWritten: [] };
|
|
163
|
+
}
|
|
164
|
+
const includeTreeshake = await fs.pathExists(path.join(targetDir, 'apps', 'treeshake-check', 'check.mjs'));
|
|
165
|
+
const verify = composeVerifyScriptFromPkg(pkg, { includeTreeshake });
|
|
166
|
+
if (!verify) {
|
|
167
|
+
console.log(chalk.gray(' not enough tools enabled to compose a verify chain — skipping (need 2+ of typecheck/lint/tests)'));
|
|
168
|
+
return { filesWritten: [] };
|
|
169
|
+
}
|
|
170
|
+
const updated = { ...pkg };
|
|
171
|
+
const scripts = { ...(updated.scripts ?? {}) };
|
|
172
|
+
scripts.verify = verify;
|
|
173
|
+
if (includeTreeshake && !scripts.treeshake) {
|
|
174
|
+
scripts.treeshake = 'pnpm --filter=*treeshake-check run check';
|
|
175
|
+
if (!scripts.pretreeshake) {
|
|
176
|
+
scripts.pretreeshake = scripts.build ? 'pnpm build' : 'echo "no build step"';
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
updated.scripts = scripts;
|
|
180
|
+
await fs.writeJson(pkgPath, updated, { spaces: 2 });
|
|
181
|
+
return { filesWritten: ['package.json'] };
|
|
141
182
|
},
|
|
142
183
|
},
|
|
143
184
|
{
|
|
@@ -242,6 +283,43 @@ const FIXERS = [
|
|
|
242
283
|
return { filesWritten: ['.size-limit.json'] };
|
|
243
284
|
},
|
|
244
285
|
},
|
|
286
|
+
{
|
|
287
|
+
target: 'treeshake-check',
|
|
288
|
+
description: 'Scaffold apps/treeshake-check — esbuild + metafile assertion that one subpath bundles cleanly',
|
|
289
|
+
appliesTo: ['Tree-shake check'],
|
|
290
|
+
outputs: [
|
|
291
|
+
'apps/treeshake-check/package.json',
|
|
292
|
+
'apps/treeshake-check/check.mjs',
|
|
293
|
+
'apps/treeshake-check/src/entry.ts',
|
|
294
|
+
],
|
|
295
|
+
riskLevel: 'safe-add',
|
|
296
|
+
canFixDrift: false,
|
|
297
|
+
async run({ targetDir, pkg }) {
|
|
298
|
+
if (!pkg) {
|
|
299
|
+
console.log(chalk.yellow(' no package.json found — skipping'));
|
|
300
|
+
return { filesWritten: [] };
|
|
301
|
+
}
|
|
302
|
+
const workspaceName = pkg.name ?? null;
|
|
303
|
+
if (!workspaceName) {
|
|
304
|
+
console.log(chalk.yellow(' package.json has no `name` — skipping'));
|
|
305
|
+
return { filesWritten: [] };
|
|
306
|
+
}
|
|
307
|
+
const { allCandidates, defaultAllowed } = inferSubpathsFromExports(pkg);
|
|
308
|
+
if (allCandidates.length < 2 || !defaultAllowed) {
|
|
309
|
+
console.log(chalk.yellow(' package.json does not expose ≥2 subpath exports — tree-shake check needs multiple subpaths to be meaningful. Skipping.'));
|
|
310
|
+
return { filesWritten: [] };
|
|
311
|
+
}
|
|
312
|
+
const allowedSubpath = defaultAllowed;
|
|
313
|
+
const forbiddenSubpaths = allCandidates.filter((s) => s !== allowedSubpath);
|
|
314
|
+
const written = await generateTreeshakeCheck(targetDir, {
|
|
315
|
+
workspaceName,
|
|
316
|
+
allowedSubpath,
|
|
317
|
+
forbiddenSubpaths,
|
|
318
|
+
});
|
|
319
|
+
console.log(chalk.dim(` Wired '${allowedSubpath}' as allowed; forbidden = [${forbiddenSubpaths.join(', ')}]. Edit apps/treeshake-check/check.mjs to tune.`));
|
|
320
|
+
return { filesWritten: written };
|
|
321
|
+
},
|
|
322
|
+
},
|
|
245
323
|
{
|
|
246
324
|
target: 'package-json',
|
|
247
325
|
description: 'Add @rtorcato/js-tooling to devDependencies',
|
|
@@ -127,9 +127,13 @@ export const CONFIG_SCHEMA = {
|
|
|
127
127
|
semanticRelease: { type: 'boolean' },
|
|
128
128
|
securityAutomation: { type: 'boolean' },
|
|
129
129
|
bundler: { type: 'string', enum: ['tsup', 'esbuild', 'vite', 'none'] },
|
|
130
|
+
treeshakeCheck: { type: 'boolean' },
|
|
130
131
|
},
|
|
131
132
|
};
|
|
132
|
-
const ALLOWED_KEYS = new Set(
|
|
133
|
+
const ALLOWED_KEYS = new Set([
|
|
134
|
+
...CONFIG_SCHEMA.required,
|
|
135
|
+
...Object.keys(CONFIG_SCHEMA.properties),
|
|
136
|
+
]);
|
|
133
137
|
export function validateProjectConfig(input) {
|
|
134
138
|
const errors = [];
|
|
135
139
|
if (typeof input !== 'object' || input === null || Array.isArray(input)) {
|
|
@@ -188,6 +192,9 @@ export function computeFileList(config) {
|
|
|
188
192
|
files.push('vite.config.ts');
|
|
189
193
|
if (config.semanticRelease)
|
|
190
194
|
files.push('release.config.mjs');
|
|
195
|
+
if (config.treeshakeCheck && config.projectType === 'library') {
|
|
196
|
+
files.push('apps/treeshake-check/package.json', 'apps/treeshake-check/check.mjs', 'apps/treeshake-check/src/entry.ts');
|
|
197
|
+
}
|
|
191
198
|
files.push('README.md');
|
|
192
199
|
return files;
|
|
193
200
|
}
|
|
@@ -188,6 +188,13 @@ async function promptForConfig() {
|
|
|
188
188
|
default: (answers) => answers.projectType === 'library',
|
|
189
189
|
when: (answers) => answers.projectType === 'library',
|
|
190
190
|
},
|
|
191
|
+
{
|
|
192
|
+
type: 'confirm',
|
|
193
|
+
name: 'treeshakeCheck',
|
|
194
|
+
message: '🌳 Add a tree-shake verification check (apps/treeshake-check)?',
|
|
195
|
+
default: false,
|
|
196
|
+
when: (answers) => answers.projectType === 'library',
|
|
197
|
+
},
|
|
191
198
|
{
|
|
192
199
|
type: 'confirm',
|
|
193
200
|
name: 'securityAutomation',
|
|
@@ -239,6 +246,7 @@ async function promptForConfig() {
|
|
|
239
246
|
semanticRelease: answers.semanticRelease || false,
|
|
240
247
|
securityAutomation: answers.securityAutomation ?? false,
|
|
241
248
|
bundler: answers.bundler || 'none',
|
|
249
|
+
treeshakeCheck: answers.treeshakeCheck || false,
|
|
242
250
|
};
|
|
243
251
|
}
|
|
244
252
|
function showNextSteps(config, _targetDir) {
|
|
@@ -290,6 +298,9 @@ function collectSkippedFixSuggestions(config) {
|
|
|
290
298
|
if (config.testing.framework === 'none') {
|
|
291
299
|
suggestions.push('Run `npx @rtorcato/js-tooling fix vitest` to add a test runner');
|
|
292
300
|
}
|
|
301
|
+
if (config.projectType === 'library' && !config.treeshakeCheck) {
|
|
302
|
+
suggestions.push('Run `npx @rtorcato/js-tooling fix treeshake-check` to add an esbuild-based tree-shake assertion');
|
|
303
|
+
}
|
|
293
304
|
suggestions.push('Run `npx @rtorcato/js-tooling doctor` any time to audit drift');
|
|
294
305
|
return suggestions;
|
|
295
306
|
}
|
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import fs from 'fs-extra';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
export const PRE_PUSH_HOOK_CONTENT = `echo "🔍 Running pre-push verify..."
|
|
4
|
+
pnpm verify
|
|
5
|
+
STATUS=$?
|
|
6
|
+
if [ $STATUS -ne 0 ]; then
|
|
7
|
+
echo "❌ Verify failed — push aborted."
|
|
8
|
+
exit 1
|
|
9
|
+
fi
|
|
10
|
+
echo "✅ Verify passed — pushing."
|
|
11
|
+
`;
|
|
3
12
|
export async function generateGitConfigs(config, targetDir) {
|
|
4
13
|
if (config.gitHooks) {
|
|
5
14
|
await generateHuskyConfig(config, targetDir);
|
|
@@ -22,6 +31,18 @@ npx lint-staged
|
|
|
22
31
|
`;
|
|
23
32
|
await fs.writeFile(preCommitPath, preCommitContent);
|
|
24
33
|
await fs.chmod(preCommitPath, 0o755);
|
|
34
|
+
// Pre-push hook — only when the package.json already has a `verify` script.
|
|
35
|
+
// In the setup flow, generatePackageJson runs before this and writes verify
|
|
36
|
+
// when 2+ tools are enabled. In the `fix husky` path, a pre-existing verify
|
|
37
|
+
// script is what unlocks the hook.
|
|
38
|
+
const pkgPath = path.join(targetDir, 'package.json');
|
|
39
|
+
if (await fs.pathExists(pkgPath)) {
|
|
40
|
+
const pkg = (await fs.readJson(pkgPath));
|
|
41
|
+
const scripts = pkg.scripts ?? {};
|
|
42
|
+
if (scripts.verify) {
|
|
43
|
+
await generatePrePushHook(targetDir);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
25
46
|
// Commit-msg hook (if commitlint is enabled)
|
|
26
47
|
if (config.commitLint) {
|
|
27
48
|
const commitMsgPath = path.join(huskyDir, 'commit-msg');
|
|
@@ -52,6 +73,13 @@ npx --no -- commitlint --edit $1
|
|
|
52
73
|
};
|
|
53
74
|
await fs.writeJson(packageJsonPath, packageJson, { spaces: 2 });
|
|
54
75
|
}
|
|
76
|
+
export async function generatePrePushHook(targetDir) {
|
|
77
|
+
const huskyDir = path.join(targetDir, '.husky');
|
|
78
|
+
await fs.ensureDir(huskyDir);
|
|
79
|
+
const prePushPath = path.join(huskyDir, 'pre-push');
|
|
80
|
+
await fs.writeFile(prePushPath, PRE_PUSH_HOOK_CONTENT);
|
|
81
|
+
await fs.chmod(prePushPath, 0o755);
|
|
82
|
+
}
|
|
55
83
|
export async function generateCommitlintConfig(targetDir) {
|
|
56
84
|
const commitlintConfigPath = path.join(targetDir, 'commitlint.config.mjs');
|
|
57
85
|
const commitlintConfig = `export { default } from '@rtorcato/js-tooling/commitlint/config'
|
|
@@ -10,6 +10,7 @@ import { generatePackageJson } from './package-json.js';
|
|
|
10
10
|
import { generateReadme } from './readme.js';
|
|
11
11
|
import { generateSecurityConfigs } from './security.js';
|
|
12
12
|
import { generateTestingConfigs } from './testing.js';
|
|
13
|
+
import { generateTreeshakeCheck, inferSubpathsFromExports } from './treeshake.js';
|
|
13
14
|
import { generateTSConfig } from './tsconfig.js';
|
|
14
15
|
const __filename = fileURLToPath(import.meta.url);
|
|
15
16
|
const __dirname = path.dirname(__filename);
|
|
@@ -45,6 +46,19 @@ export async function generateConfigs(config, targetDir) {
|
|
|
45
46
|
if (config.bundler !== 'none') {
|
|
46
47
|
await generateBuildConfigs(config, targetDir);
|
|
47
48
|
}
|
|
49
|
+
// Tree-shake verification check (libraries only, when opted-in)
|
|
50
|
+
if (config.treeshakeCheck && config.projectType === 'library') {
|
|
51
|
+
const pkgPath = path.join(targetDir, 'package.json');
|
|
52
|
+
const pkg = (await fs.readJson(pkgPath));
|
|
53
|
+
const { allCandidates, defaultAllowed } = inferSubpathsFromExports(pkg);
|
|
54
|
+
if (defaultAllowed && allCandidates.length >= 2) {
|
|
55
|
+
await generateTreeshakeCheck(targetDir, {
|
|
56
|
+
workspaceName: config.projectName,
|
|
57
|
+
allowedSubpath: defaultAllowed,
|
|
58
|
+
forbiddenSubpaths: allCandidates.filter((s) => s !== defaultAllowed),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
48
62
|
// Generate README
|
|
49
63
|
await generateReadme(config, targetDir);
|
|
50
64
|
// Copy ts-reset if TypeScript is enabled
|
|
@@ -6,6 +6,7 @@ export async function generatePackageJson(config, targetDir) {
|
|
|
6
6
|
if (await fs.pathExists(packageJsonPath)) {
|
|
7
7
|
existingPackageJson = await fs.readJson(packageJsonPath);
|
|
8
8
|
}
|
|
9
|
+
const includeTreeshake = Boolean(config.treeshakeCheck && config.projectType === 'library');
|
|
9
10
|
const packageJson = {
|
|
10
11
|
name: config.projectName,
|
|
11
12
|
version: '0.1.0',
|
|
@@ -13,7 +14,7 @@ export async function generatePackageJson(config, targetDir) {
|
|
|
13
14
|
type: 'module',
|
|
14
15
|
...existingPackageJson,
|
|
15
16
|
scripts: {
|
|
16
|
-
...getScripts(config),
|
|
17
|
+
...getScripts(config, { includeTreeshake }),
|
|
17
18
|
...existingPackageJson?.scripts,
|
|
18
19
|
},
|
|
19
20
|
dependencies: {
|
|
@@ -44,7 +45,7 @@ export async function generatePackageJson(config, targetDir) {
|
|
|
44
45
|
}
|
|
45
46
|
await fs.writeJson(packageJsonPath, packageJson, { spaces: 2 });
|
|
46
47
|
}
|
|
47
|
-
function getScripts(config) {
|
|
48
|
+
function getScripts(config, opts = {}) {
|
|
48
49
|
const scripts = {};
|
|
49
50
|
// TypeScript scripts
|
|
50
51
|
if (config.typescript.enabled) {
|
|
@@ -99,8 +100,67 @@ function getScripts(config) {
|
|
|
99
100
|
if (config.semanticRelease) {
|
|
100
101
|
scripts['release'] = 'semantic-release';
|
|
101
102
|
}
|
|
103
|
+
if (opts.includeTreeshake) {
|
|
104
|
+
scripts['pretreeshake'] = scripts['build'] ? 'pnpm build' : 'echo "no build step"';
|
|
105
|
+
scripts['treeshake'] = 'pnpm --filter=*treeshake-check run check';
|
|
106
|
+
}
|
|
107
|
+
const verify = composeVerifyScript(config, opts);
|
|
108
|
+
if (verify) {
|
|
109
|
+
scripts['verify'] = verify;
|
|
110
|
+
}
|
|
102
111
|
return scripts;
|
|
103
112
|
}
|
|
113
|
+
export function composeVerifyScript(config, opts = {}) {
|
|
114
|
+
const cmds = [];
|
|
115
|
+
if (config.typescript.enabled)
|
|
116
|
+
cmds.push('pnpm typecheck');
|
|
117
|
+
if (config.linting.tool === 'biome' || config.linting.tool === 'both') {
|
|
118
|
+
cmds.push('pnpm check');
|
|
119
|
+
}
|
|
120
|
+
else if (config.linting.tool === 'eslint') {
|
|
121
|
+
cmds.push('pnpm lint');
|
|
122
|
+
}
|
|
123
|
+
if (config.testing.framework === 'vitest') {
|
|
124
|
+
cmds.push('pnpm exec vitest run');
|
|
125
|
+
}
|
|
126
|
+
else if (config.testing.framework === 'jest') {
|
|
127
|
+
cmds.push('pnpm test --ci');
|
|
128
|
+
}
|
|
129
|
+
else if (config.testing.framework === 'playwright') {
|
|
130
|
+
cmds.push('pnpm test:e2e');
|
|
131
|
+
}
|
|
132
|
+
if (opts.includeTreeshake)
|
|
133
|
+
cmds.push('pnpm treeshake');
|
|
134
|
+
return cmds.length >= 2 ? cmds.join(' && ') : null;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Derive the verify chain from a real package.json's scripts + deps.
|
|
138
|
+
* Used by `fix verify`, where we shouldn't assume tools beyond what the
|
|
139
|
+
* project actually has.
|
|
140
|
+
*/
|
|
141
|
+
export function composeVerifyScriptFromPkg(pkg, opts = {}) {
|
|
142
|
+
const scripts = pkg.scripts ?? {};
|
|
143
|
+
const deps = {
|
|
144
|
+
...(pkg.dependencies ?? {}),
|
|
145
|
+
...(pkg.devDependencies ?? {}),
|
|
146
|
+
};
|
|
147
|
+
const cmds = [];
|
|
148
|
+
if (scripts.typecheck || deps.typescript)
|
|
149
|
+
cmds.push('pnpm typecheck');
|
|
150
|
+
if (scripts.check)
|
|
151
|
+
cmds.push('pnpm check');
|
|
152
|
+
else if (scripts.lint && !scripts.check)
|
|
153
|
+
cmds.push('pnpm lint');
|
|
154
|
+
if (deps.vitest)
|
|
155
|
+
cmds.push('pnpm exec vitest run');
|
|
156
|
+
else if (deps.jest)
|
|
157
|
+
cmds.push('pnpm test --ci');
|
|
158
|
+
else if (deps['@playwright/test'])
|
|
159
|
+
cmds.push('pnpm test:e2e');
|
|
160
|
+
if (opts.includeTreeshake)
|
|
161
|
+
cmds.push('pnpm treeshake');
|
|
162
|
+
return cmds.length >= 2 ? cmds.join(' && ') : null;
|
|
163
|
+
}
|
|
104
164
|
function getDependencies(config) {
|
|
105
165
|
const deps = {};
|
|
106
166
|
// TypeScript
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import fs from 'fs-extra';
|
|
3
|
+
/**
|
|
4
|
+
* Inspect package.exports and return non-root subpath candidates (e.g. './foo' → 'foo').
|
|
5
|
+
* Skips '.', types-only entries, and wildcard patterns ('./*').
|
|
6
|
+
*/
|
|
7
|
+
export function inferSubpathsFromExports(pkg) {
|
|
8
|
+
const exports = pkg?.exports;
|
|
9
|
+
if (!exports || typeof exports !== 'object') {
|
|
10
|
+
return { allCandidates: [], defaultAllowed: null };
|
|
11
|
+
}
|
|
12
|
+
const candidates = [];
|
|
13
|
+
for (const key of Object.keys(exports)) {
|
|
14
|
+
if (key === '.' || !key.startsWith('./'))
|
|
15
|
+
continue;
|
|
16
|
+
if (key.includes('*'))
|
|
17
|
+
continue;
|
|
18
|
+
const trimmed = key.replace(/^\.\//, '');
|
|
19
|
+
if (!trimmed)
|
|
20
|
+
continue;
|
|
21
|
+
candidates.push(trimmed);
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
allCandidates: candidates,
|
|
25
|
+
defaultAllowed: candidates[0] ?? null,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function renderCheckScript(opts) {
|
|
29
|
+
const allowed = JSON.stringify([opts.allowedSubpath]);
|
|
30
|
+
const forbidden = JSON.stringify(opts.forbiddenSubpaths, null, '\t');
|
|
31
|
+
return `import { build } from 'esbuild'
|
|
32
|
+
|
|
33
|
+
const ALLOWED_MODULES = ${allowed}
|
|
34
|
+
|
|
35
|
+
const FORBIDDEN_MODULES = ${forbidden}
|
|
36
|
+
|
|
37
|
+
const result = await build({
|
|
38
|
+
\tentryPoints: ['src/entry.ts'],
|
|
39
|
+
\tbundle: true,
|
|
40
|
+
\tformat: 'esm',
|
|
41
|
+
\tplatform: 'browser',
|
|
42
|
+
\tconditions: ['import', 'browser'],
|
|
43
|
+
\twrite: false,
|
|
44
|
+
\tmetafile: true,
|
|
45
|
+
\tminify: false,
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
const inputs = Object.keys(result.metafile.inputs)
|
|
49
|
+
const distInputs = inputs.filter((p) => p.includes('/dist/'))
|
|
50
|
+
const leaks = distInputs.filter((p) =>
|
|
51
|
+
\tFORBIDDEN_MODULES.some((m) => new RegExp(\`/dist/\${m}/\`).test(p))
|
|
52
|
+
)
|
|
53
|
+
const allowed = distInputs.filter((p) =>
|
|
54
|
+
\tALLOWED_MODULES.some((m) => new RegExp(\`/dist/\${m}/\`).test(p))
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
const bundleBytes = result.outputFiles?.[0]?.contents.byteLength ?? 0
|
|
58
|
+
|
|
59
|
+
console.log(\`Bundle size: \${bundleBytes} bytes\`)
|
|
60
|
+
console.log(\`dist inputs in bundle (\${distInputs.length}):\`)
|
|
61
|
+
for (const p of distInputs) console.log(\` \${p}\`)
|
|
62
|
+
|
|
63
|
+
if (leaks.length > 0) {
|
|
64
|
+
\tconsole.error('\\n❌ Tree-shaking leak — forbidden modules in the bundle:')
|
|
65
|
+
\tfor (const p of leaks) console.error(\` \${p}\`)
|
|
66
|
+
\tprocess.exit(1)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (allowed.length === 0) {
|
|
70
|
+
\tconsole.error(
|
|
71
|
+
\t\t'\\n❌ Expected at least one allowed-subpath input — entry may have failed to resolve.'
|
|
72
|
+
\t)
|
|
73
|
+
\tprocess.exit(1)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
console.log('\\n✅ Tree-shaking OK — only allowed inputs present.')
|
|
77
|
+
`;
|
|
78
|
+
}
|
|
79
|
+
function renderEntry(workspaceName, allowedSubpath) {
|
|
80
|
+
return `export * from '${workspaceName}/${allowedSubpath}'\n`;
|
|
81
|
+
}
|
|
82
|
+
export async function generateTreeshakeCheck(targetDir, opts) {
|
|
83
|
+
const appDir = opts.appDir ?? 'apps/treeshake-check';
|
|
84
|
+
const root = path.join(targetDir, appDir);
|
|
85
|
+
await fs.ensureDir(path.join(root, 'src'));
|
|
86
|
+
const pkgName = `${opts.workspaceName.replace(/^@/, '').replace(/\//g, '-')}-treeshake-check`;
|
|
87
|
+
const packageJson = {
|
|
88
|
+
name: pkgName,
|
|
89
|
+
version: '0.0.0',
|
|
90
|
+
private: true,
|
|
91
|
+
type: 'module',
|
|
92
|
+
scripts: {
|
|
93
|
+
check: 'node check.mjs',
|
|
94
|
+
},
|
|
95
|
+
dependencies: {
|
|
96
|
+
[opts.workspaceName]: 'workspace:*',
|
|
97
|
+
},
|
|
98
|
+
devDependencies: {
|
|
99
|
+
esbuild: '^0.28.0',
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
const checkPath = path.join(root, 'check.mjs');
|
|
103
|
+
const entryPath = path.join(root, 'src', 'entry.ts');
|
|
104
|
+
const pkgPath = path.join(root, 'package.json');
|
|
105
|
+
await fs.writeJson(pkgPath, packageJson, { spaces: 2 });
|
|
106
|
+
await fs.writeFile(checkPath, renderCheckScript(opts));
|
|
107
|
+
await fs.writeFile(entryPath, renderEntry(opts.workspaceName, opts.allowedSubpath));
|
|
108
|
+
return [
|
|
109
|
+
path.join(appDir, 'package.json'),
|
|
110
|
+
path.join(appDir, 'check.mjs'),
|
|
111
|
+
path.join(appDir, 'src', 'entry.ts'),
|
|
112
|
+
];
|
|
113
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rtorcato/js-tooling",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.10.0",
|
|
4
4
|
"description": "JavaScript and TypeScript tooling for Node.js, React, Next.js, and Vitest.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -63,6 +63,10 @@
|
|
|
63
63
|
"tooling/vitest/vitest.setup.d.mts",
|
|
64
64
|
"tooling/vitest/jsdom-shims.mjs",
|
|
65
65
|
"tooling/vitest/jsdom-shims.d.mts",
|
|
66
|
+
"tooling/tests/exports-resolution.mjs",
|
|
67
|
+
"tooling/tests/exports-resolution.d.mts",
|
|
68
|
+
"tooling/tests/ssr-safety.mjs",
|
|
69
|
+
"tooling/tests/ssr-safety.d.mts",
|
|
66
70
|
"tooling/tsup/index.ts",
|
|
67
71
|
"tooling/biome/biome.json",
|
|
68
72
|
"tooling/semantic-release/*.mjs",
|
|
@@ -142,6 +146,14 @@
|
|
|
142
146
|
"./semantic-release/docker": {
|
|
143
147
|
"types": "./tooling/semantic-release/docker.d.mts",
|
|
144
148
|
"import": "./tooling/semantic-release/docker.mjs"
|
|
149
|
+
},
|
|
150
|
+
"./tests/exports-resolution": {
|
|
151
|
+
"types": "./tooling/tests/exports-resolution.d.mts",
|
|
152
|
+
"import": "./tooling/tests/exports-resolution.mjs"
|
|
153
|
+
},
|
|
154
|
+
"./tests/ssr-safety": {
|
|
155
|
+
"types": "./tooling/tests/ssr-safety.d.mts",
|
|
156
|
+
"import": "./tooling/tests/ssr-safety.mjs"
|
|
145
157
|
}
|
|
146
158
|
},
|
|
147
159
|
"dependencies": {
|
|
@@ -158,7 +170,7 @@
|
|
|
158
170
|
"@eslint/js": "^9.38.0",
|
|
159
171
|
"@ianvs/prettier-plugin-sort-imports": "^4.4.2",
|
|
160
172
|
"@next/eslint-plugin-next": "^16.2.7",
|
|
161
|
-
"@playwright/test": "^1.
|
|
173
|
+
"@playwright/test": "^1.60.0",
|
|
162
174
|
"@semantic-release/changelog": "^6.0.3",
|
|
163
175
|
"@semantic-release/commit-analyzer": "^13.0.1",
|
|
164
176
|
"@semantic-release/exec": "^7.1.0",
|
|
@@ -168,7 +180,7 @@
|
|
|
168
180
|
"@semantic-release/release-notes-generator": "^14.1.1",
|
|
169
181
|
"@total-typescript/ts-reset": "0.6.1",
|
|
170
182
|
"@types/fs-extra": "^11.0.4",
|
|
171
|
-
"@types/node": "^25.9.
|
|
183
|
+
"@types/node": "^25.9.2",
|
|
172
184
|
"@typescript-eslint/eslint-plugin": "^8.46.2",
|
|
173
185
|
"@typescript-eslint/parser": "^8.46.2",
|
|
174
186
|
"@vitejs/plugin-react": "^5.1.0",
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface ExportsResolutionTestOptions {
|
|
2
|
+
/** Absolute path to the package.json whose `exports` map will be validated. */
|
|
3
|
+
packageJsonPath: string
|
|
4
|
+
/** Absolute path to the source directory whose subfolders are cross-checked. */
|
|
5
|
+
srcDir: string
|
|
6
|
+
/** Folder names under `srcDir` to skip (e.g., `'tests'`, `'common'`). */
|
|
7
|
+
excludeDirs?: string[]
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Asserts that a package.json `exports` map stays in sync with its source folders.
|
|
12
|
+
* Call from a Vitest test file; generates one describe block plus one it per folder.
|
|
13
|
+
*/
|
|
14
|
+
export function runExportsResolutionTest(options: ExportsResolutionTestOptions): void
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { readdirSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Asserts that a package.json `exports` map stays in sync with its source folders.
|
|
6
|
+
*
|
|
7
|
+
* For every folder under `srcDir` (excluding `excludeDirs`), the package.json must
|
|
8
|
+
* expose a matching `./<name>` subpath export. Conversely, every subpath in the
|
|
9
|
+
* `exports` map (other than `.`) must point at a folder that exists in `srcDir`.
|
|
10
|
+
*
|
|
11
|
+
* Call this from a Vitest test file; it generates one `describe` block plus one
|
|
12
|
+
* `it` per folder, so failures pinpoint the drift.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```ts
|
|
16
|
+
* // src/tests/exports-resolution.test.ts
|
|
17
|
+
* import { fileURLToPath } from 'node:url'
|
|
18
|
+
* import { runExportsResolutionTest } from '@rtorcato/js-tooling/tests/exports-resolution'
|
|
19
|
+
*
|
|
20
|
+
* runExportsResolutionTest({
|
|
21
|
+
* packageJsonPath: fileURLToPath(new URL('../../package.json', import.meta.url)),
|
|
22
|
+
* srcDir: fileURLToPath(new URL('../', import.meta.url)),
|
|
23
|
+
* excludeDirs: ['tests'],
|
|
24
|
+
* })
|
|
25
|
+
* ```
|
|
26
|
+
*
|
|
27
|
+
* @param {object} options
|
|
28
|
+
* @param {string} options.packageJsonPath Absolute path to the package.json under test.
|
|
29
|
+
* @param {string} options.srcDir Absolute path to the source folder whose subdirectories should be cross-checked.
|
|
30
|
+
* @param {string[]} [options.excludeDirs] Folder names under `srcDir` to skip (e.g., `tests`, `common`).
|
|
31
|
+
*/
|
|
32
|
+
export function runExportsResolutionTest({ packageJsonPath, srcDir, excludeDirs = [] }) {
|
|
33
|
+
const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf-8'))
|
|
34
|
+
|
|
35
|
+
if (!pkg.exports || typeof pkg.exports !== 'object') {
|
|
36
|
+
throw new Error(`exports-resolution: package.json at ${packageJsonPath} has no exports map`)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const exportSubpaths = new Set(
|
|
40
|
+
Object.keys(pkg.exports)
|
|
41
|
+
.filter((k) => k !== '.')
|
|
42
|
+
.map((k) => k.replace(/^\.\//, ''))
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
const excluded = new Set(excludeDirs)
|
|
46
|
+
const moduleDirs = readdirSync(srcDir, { withFileTypes: true })
|
|
47
|
+
.filter((d) => d.isDirectory() && !excluded.has(d.name))
|
|
48
|
+
.map((d) => d.name)
|
|
49
|
+
.sort()
|
|
50
|
+
|
|
51
|
+
describe('package.json exports map', () => {
|
|
52
|
+
it('has at least one module', () => {
|
|
53
|
+
expect(moduleDirs.length).toBeGreaterThan(0)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
for (const dir of moduleDirs) {
|
|
57
|
+
it(`exposes ./${dir}`, () => {
|
|
58
|
+
expect(exportSubpaths.has(dir)).toBe(true)
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
it('has no exports entries pointing at missing src/ folders', () => {
|
|
63
|
+
const moduleDirSet = new Set(moduleDirs)
|
|
64
|
+
const orphans = [...exportSubpaths].filter((k) => !moduleDirSet.has(k))
|
|
65
|
+
expect(orphans).toEqual([])
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface SsrSafetyTestOptions {
|
|
2
|
+
/** Absolute path to the source directory. */
|
|
3
|
+
srcDir: string
|
|
4
|
+
/** Folder names under `srcDir` to skip (e.g., `'tests'`, `'common'`). */
|
|
5
|
+
excludeDirs?: string[]
|
|
6
|
+
/** Entry filename inside each folder. Default: `'index.ts'`. */
|
|
7
|
+
moduleEntry?: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Asserts that every module folder under `srcDir` can be imported in a Node
|
|
12
|
+
* environment without throwing. Call from a Vitest test file.
|
|
13
|
+
*/
|
|
14
|
+
export function runSsrSafetyTest(options: SsrSafetyTestOptions): void
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { readdirSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { pathToFileURL } from 'node:url'
|
|
4
|
+
import { describe, expect, it } from 'vitest'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Asserts that every module folder under `srcDir` can be imported in a Node
|
|
8
|
+
* environment without throwing (no DOM, no `window`, no `document`).
|
|
9
|
+
*
|
|
10
|
+
* For each folder under `srcDir` (excluding `excludeDirs`), the helper resolves
|
|
11
|
+
* `<srcDir>/<name>/<moduleEntry>` and `await import(...)` it. If the import
|
|
12
|
+
* throws or the module accesses a missing global at top level, the test fails.
|
|
13
|
+
*
|
|
14
|
+
* Use this as a contract test for libraries that promise SSR safety — i.e., that
|
|
15
|
+
* importing a module is always safe even when its underlying API isn't available.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```ts
|
|
19
|
+
* // src/tests/ssr-safety.test.ts (run with vitest --environment node)
|
|
20
|
+
* import { fileURLToPath } from 'node:url'
|
|
21
|
+
* import { runSsrSafetyTest } from '@rtorcato/js-tooling/tests/ssr-safety'
|
|
22
|
+
*
|
|
23
|
+
* runSsrSafetyTest({
|
|
24
|
+
* srcDir: fileURLToPath(new URL('../', import.meta.url)),
|
|
25
|
+
* excludeDirs: ['tests'],
|
|
26
|
+
* })
|
|
27
|
+
* ```
|
|
28
|
+
*
|
|
29
|
+
* @param {object} options
|
|
30
|
+
* @param {string} options.srcDir Absolute path to the source folder.
|
|
31
|
+
* @param {string[]} [options.excludeDirs] Folder names under `srcDir` to skip.
|
|
32
|
+
* @param {string} [options.moduleEntry] Entry filename inside each folder. Default: `'index.ts'`.
|
|
33
|
+
*/
|
|
34
|
+
export function runSsrSafetyTest({ srcDir, excludeDirs = [], moduleEntry = 'index.ts' }) {
|
|
35
|
+
const excluded = new Set(excludeDirs)
|
|
36
|
+
const moduleDirs = readdirSync(srcDir, { withFileTypes: true })
|
|
37
|
+
.filter((d) => d.isDirectory() && !excluded.has(d.name))
|
|
38
|
+
.map((d) => d.name)
|
|
39
|
+
.sort()
|
|
40
|
+
|
|
41
|
+
describe('SSR safety', () => {
|
|
42
|
+
it('has at least one module', () => {
|
|
43
|
+
expect(moduleDirs.length).toBeGreaterThan(0)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
for (const dir of moduleDirs) {
|
|
47
|
+
it(`imports ./${dir}/${moduleEntry} without throwing`, async () => {
|
|
48
|
+
const moduleUrl = pathToFileURL(join(srcDir, dir, moduleEntry)).href
|
|
49
|
+
await expect(import(moduleUrl)).resolves.toBeDefined()
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
}
|