Skip to content
Open
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
77 changes: 76 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 4 additions & 3 deletions recipes/cjs-to-esm/package.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
{
"name": "@nodejs/cjs-to-esm",
"name": "@nodejs/cjs-to-esm",
"version": "1.0.0",
"description": "Codemod to assist CommonJS -> ESM migrations (imports, exports, package.json guidance)",
"type": "module",
"scripts": {
"test": "echo \"The test will be runned when implementation is done.\"",
"test": "node --run test:package-json",
"test:import": "npx codemod jssg test -l typescript ./src/import-process.ts ./tests/import",
"test:export": "npx codemod jssg test -l typescript ./src/export-process.ts ./tests/export",
"test:package-json": "npx codemod jssg test -l json ./src/package-json-process.ts ./tests/package-json",
Expand All @@ -23,6 +23,7 @@
"@codemod.com/jssg-types": "^1.3.1"
},
"dependencies": {
"@nodejs/codemod-utils": "*"
"@nodejs/codemod-utils": "*",
"@vltpkg/semver": "^1.0.0-rc.18"
}
}
194 changes: 193 additions & 1 deletion recipes/cjs-to-esm/src/package-json-process.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,206 @@
import type { Edit, SgRoot } from '@codemod.com/jssg-types/main';
import type Json from '@codemod.com/jssg-types/langs/json';
import { intersects, satisfies, validRange } from '@vltpkg/semver';

const REQUIRED_NODE_ENGINE = '^20.19.0 || >=22.12.0';
const REQUIRED_NODE_ANCHORS = ['20.19.0', '20.20.0', '22.12.0', '24.0.0'];

/**
* Given a current node engine range, returns the best next node engine range to use for an ESM package.
* If the current range is invalid, doesn't intersect with the required range, or doesn't cover all the required anchors, the required range will be returned.
* Otherwise, the current range will be returned.
* This allows to preserve custom node engine ranges as much as possible, while ensuring the resulting range is valid for ESM packages.
* @param current The current node engine range.
* @returns The best next node engine range to use for an ESM package.
*/
function chooseBestNodeEngine(current: unknown): string {
if (typeof current !== 'string' || !validRange(current)) {
return REQUIRED_NODE_ENGINE;
}

if (current === REQUIRED_NODE_ENGINE) {
return current;
}

if (!intersects(current, REQUIRED_NODE_ENGINE)) {
return REQUIRED_NODE_ENGINE;
}

const coversRequiredAnchors = REQUIRED_NODE_ANCHORS.every((version) =>
satisfies(version, current),
);

return coversRequiredAnchors ? REQUIRED_NODE_ENGINE : current;
}

function appendPairToObject(
objectText: string,
pairSnippet: string,
pairIndent: string,
closingIndent: string,
): string {
if (objectText.trim() === '{}') {
return `{\n${pairIndent}${pairSnippet}\n${closingIndent}}`;
}

return objectText.replace(
/\n([ \t]*)}$/,
`,\n${pairIndent}${pairSnippet}\n$1}`,
);
}

/**
* @see https://github.com/nodejs/package-examples/tree/main/guide/05-cjs-esm-migration/migrating-package-json
*/
export default function transform(root: SgRoot<Json>): string | null {
const rootNode = root.root();
const edits: Edit[] = [];
const topLevelObject = rootNode.find({
rule: {
kind: 'object',
},
});

const typePariFeld = rootNode
.find({
rule: {
kind: 'pair',
has: {
kind: 'string',
field: 'key',
has: {
kind: 'string_content',
regex: '^type$',
},
},
inside: {
kind: 'object',
},
},
})
?.field('value');
const topLevelInsertions: string[] = [];

if (typePariFeld && typePariFeld.text() !== '"module"') {
edits.push(typePariFeld.replace('"module"'));
} else if (!typePariFeld) {
topLevelInsertions.push('"type": "module"');
}

const enginesValueField = rootNode
.find({
rule: {
kind: 'pair',
has: {
kind: 'string',
field: 'key',
has: {
kind: 'string_content',
regex: '^engines$',
},
},
inside: {
kind: 'object',
},
},
})
?.field('value');

if (!enginesValueField) {
topLevelInsertions.push(
`"engines": {\n "node": ${JSON.stringify(REQUIRED_NODE_ENGINE)}\n }`,
);
} else if (enginesValueField.kind() !== 'object') {
edits.push(
enginesValueField.replace(
`{\n "node": ${JSON.stringify(REQUIRED_NODE_ENGINE)}\n }`,
),
);
} else {
const nodeEngineValueField = enginesValueField
.find({
rule: {
kind: 'pair',
has: {
kind: 'string',
field: 'key',
has: {
kind: 'string_content',
regex: '^node$',
},
},
inside: {
kind: 'object',
},
},
})
?.field('value');

if (!nodeEngineValueField) {
edits.push(
enginesValueField.replace(
appendPairToObject(
enginesValueField.text(),
`"node": ${JSON.stringify(REQUIRED_NODE_ENGINE)}`,
' ',
' ',
),
),
);
} else {
const currentLiteral = nodeEngineValueField
.find({
rule: {
kind: 'string_content',
},
})
?.text();
const nextRange = chooseBestNodeEngine(currentLiteral);

if (currentLiteral !== nextRange) {
edits.push(nodeEngineValueField.replace(JSON.stringify(nextRange)));
}
}
}

if (topLevelObject && topLevelInsertions.length > 0) {
let nextTopLevelText = topLevelObject
.text()
.replace(/\.cjs(?=")/g, '.mjs')
.replace(/\.cts(?=")/g, '.mts');

for (const insertion of topLevelInsertions) {
nextTopLevelText = appendPairToObject(
nextTopLevelText,
insertion,
' ',
'',
);
}
edits.push(topLevelObject.replace(nextTopLevelText));
} else {
const cjsStrings = rootNode.findAll({
rule: {
kind: 'string_content',
regex: '\\.cjs$',
},
});

for (const cjsString of cjsStrings) {
edits.push(cjsString.replace(cjsString.text().replace(/\.cjs$/, '.mjs')));
}

const ctsStrings = rootNode.findAll({
rule: {
kind: 'string_content',
regex: '\\.cts$',
},
});

// do some stuff
for (const ctsString of ctsStrings) {
edits.push(ctsString.replace(ctsString.text().replace(/\.cts$/, '.mts')));
}
}

if (!edits.length) return null;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "fixture-1",
"version": "1.0.0",
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"type": "module"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "fixture-1",
"version": "1.0.0",
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "fixture-2",
"type": "module",
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "fixture-2",
"type": "commonjs",
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "fixture-3",
"type": "module",
"main": "./index.mjs",
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "fixture-3",
"type": "module",
"main": "./index.cjs",
"engines": {
"node": ">=18"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "fixture-4",
"main": "./cli.mjs",
"type": "module",
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"name": "fixture-4",
"main": "./cli.cjs"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "fixture-5",
"types": "./index.d.mts",
"type": "module",
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"name": "fixture-5",
"types": "./index.d.cts"
}
Loading