Skip to content

Commit

Permalink
fix: generate correct rewritten import statements
Browse files Browse the repository at this point in the history
  • Loading branch information
ayuhito committed Jul 26, 2022
1 parent 8144841 commit b7256df
Show file tree
Hide file tree
Showing 7 changed files with 171 additions and 100 deletions.
2 changes: 2 additions & 0 deletions .eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,7 @@ module.exports = {
rules: {
"no-console": "off",
"import/no-default-export": "off",
semi: ["error", "always"],
quotes: ["error", "single", { avoidEscape: true }],
},
};
19 changes: 10 additions & 9 deletions src/core/context.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,32 @@
import type { ModuleMap, Options } from '../types'
import type { Context, ModuleMap, Options } from '../types';

const CDN_HOST = 'https://cdn.jsdelivr.net'
const CDN_HOST = 'https://cdn.jsdelivr.net';

const createContext = (options?: Options) => {
const createContext = (options?: Options): Context => {
const modulesMap: ModuleMap = new Map();
const cwd = options?.cwd ?? process.cwd();

if (options?.modules) {
for (const { module, transform } of options.modules) {
// If no transform given, use full ESM lib
if (typeof transform === 'function') {
modulesMap.set(module, transform)
modulesMap.set(module, transform);
} else {
modulesMap.set(module, module);
}
}
}

const endpoint = options?.endpoint ?? 'npm'
const endpoint = options?.endpoint ?? 'npm';

return {
// The modules that are being processed. Set all to external if options.modules === true
modules: modulesMap,
// The current working directory.
cwd: options?.cwd ?? process.cwd(),
cwd,
// The endpoint that is being used.
host: `${CDN_HOST}/${endpoint}`,
}
}
};
};

export { CDN_HOST, createContext }
export { CDN_HOST, createContext };
71 changes: 38 additions & 33 deletions src/core/transform.ts
Original file line number Diff line number Diff line change
@@ -1,65 +1,70 @@
import { init, parse } from 'es-module-lexer'
import { init, parse } from 'es-module-lexer';
import MagicString from 'magic-string';

import { ModuleMap, TransformFunction } from '../types';

type ImportTuple = [OriginalImport: string, RenamedImport: string];

const generateImportTuple = (importElem: string): ImportTuple[] => {
const tuple: ImportTuple[] = [];
import { Context, ImportStatementTuple, ImportTuple, ModuleMapReturn } from '../types';
import { getVersion } from './version';

const generateImportTuple = (importElem: string): ImportTuple => {
const importRename = importElem.split(' as ').map(e => e.trim());
if (importRename.length === 1)
tuple.push([importRename[0], importRename[0]]); // 'map' into ['map', 'map']
else
tuple.push([importRename[0], importRename[1]]); // 'merge as LodashMerge' into ['merge', 'LodashMerge']
return [importRename[0], importRename[0]]; // 'map' into ['map', 'map']

return tuple
}
return [importRename[0], importRename[1]]; // 'merge as LodashMerge' into ['merge', 'LodashMerge']
};

const generateImportStatements = (importModule: string, importTuples: ImportTuple[], transform: TransformFunction | string): string => {
const importStatements = importTuples.map(importTuple => {
const [originalImport, renamedImport] = importTuple;
const newModulePath = typeof transform === 'string' ? transform : transform(importModule, originalImport);
return `import ${renamedImport} from '${newModulePath}'`;
}).join('\n');
return importStatements;
}
const generateImportStatement = async (importModule: string, importTuple: ImportTuple, transform: ModuleMapReturn, ctx: Context): Promise<string> => {
const version = await getVersion(importModule, ctx.cwd);
const [originalImport, renamedImport] = importTuple;
const transformedModule = typeof transform === 'string' ? transform : transform(`${importModule}@${version}`, originalImport);
const newModulePath = `${ctx.host}/${transformedModule}/+esm`;
return `import ${renamedImport} from '${newModulePath}';`;
};

const updateCode = (code: string, importStatements: ImportStatementTuple[]) => {
const magicCode = new MagicString(code);
// New rewritten statements will be shorter or longer offsetting indexes for other imports
let offset = 0;
for (const importStatementTuple of importStatements) {
const [importStatement, statementStart, statementEnd] = importStatementTuple;
magicCode.remove(statementStart + offset, statementEnd + offset);
magicCode.appendLeft(statementStart + offset, importStatement);
offset += importStatement.length - (statementEnd - statementStart);
}

return magicCode.toString();
};

const transformImports = async (code: string, modules: ModuleMap) => {
const transformImports = async (code: string, ctx: Context) => {
await init;

const [imports] = parse(code);
const magicCode = new MagicString(code);
const importStatements: ImportStatementTuple[] = [];

for (const importSpecifier of imports) {
const importModule = importSpecifier.n; // e.g. 'lodash'
if (importModule === undefined)
throw new Error(`Bad import specifier: ${importSpecifier}`);

// If module isn't included, skip to next importSpecifier
const transformFunction = modules.get(importModule);
const transformFunction = ctx.modules.get(importModule);
if (transformFunction !== undefined) {
// e.g. import { map, merge as LodashMerge } from "lodash"
const importStatement = code.slice(importSpecifier.ss, importSpecifier.se);
// Returns an array of import consts e.g. ['map', 'merge as LodashMerge']
const importElems = importStatement.slice(importStatement.indexOf('{') + 1, importStatement.indexOf('}')).split(',');

// Setup import tuple
const newImportStatements: string[] = [];
for (const importElem of importElems) {
const importTuples = generateImportTuple(importElem);

const importTuple = generateImportTuple(importElem);
// Generate rewritten import statements
const newImportStatements = generateImportStatements(importModule, importTuples, transformFunction);
console.log(newImportStatements)
magicCode.overwrite(importSpecifier.ss, importSpecifier.se, newImportStatements);

console.log(magicCode.toString())
// eslint-disable-next-line no-await-in-loop
newImportStatements.push(await generateImportStatement(importModule, importTuple, transformFunction, ctx));
}
importStatements.push([newImportStatements.join('\n'), importSpecifier.ss, importSpecifier.se]);
}
}
return code;
}
return updateCode(code, await Promise.all(importStatements));
};

export { generateImportTuple, transformImports }
export { generateImportStatement, generateImportTuple, transformImports, updateCode };
18 changes: 9 additions & 9 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
/* eslint-disable unicorn/no-null */
import { createUnplugin } from 'unplugin'
import { createUnplugin } from 'unplugin';

import { createContext } from './core/context';
import { transformImports } from "./core/transform"
import { getVersion } from './core/version'
import type { Options } from './types'
import { transformImports } from './core/transform';
import { getVersion } from './core/version';
import type { Options } from './types';


export default createUnplugin<Options>(options => {
Expand All @@ -19,13 +19,13 @@ export default createUnplugin<Options>(options => {
return {
id: hostId,
external: true,
}
};
}

return null
return null;
},
async transform(code) {
return transformImports(code, ctx.modules)
return transformImports(code, ctx);
}
})
})
});
});
20 changes: 18 additions & 2 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,30 @@ export type TransformFunction = (moduleName: string, importName: string) => stri
export interface ModuleOpts {
module: string,
transform?: TransformFunction,
version?: string,
}

export type ModuleMap = Map<string, string | TransformFunction>
export type ModuleMapReturn = string | TransformFunction
export type ModuleMap = Map<string, ModuleMapReturn>

export interface Options {
cwd?: string,
enforce?: 'pre' | 'post',
endpoint?: "npm" | "gh",
endpoint?: 'npm' | 'gh',

modules?: ModuleOpts[],
}

export interface Context {
modules: ModuleMap,
cwd: string,
host: string,
}

export type ImportTuple = [OriginalImport: string, RenamedImport: string];

/**
* Used to replace existing import statements using indexes with new statements
* @example ['import { map, merge as LodashMerge } from "lodash"', 0, 30]
*/
export type ImportStatementTuple = [NewImportStatement: string, OrigStatementStart: number, OrigStatementEnd: number];
94 changes: 70 additions & 24 deletions tests/core/transform.test.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,71 @@
import { describe, expect, it } from "vitest";

import { transformImports } from "../../src/core/transform";

describe.skip("Transform", () => {
const code = `
import { import1, import2 as import3 } from 'test-package';
import { importNested } from 'test-package/nested';
import defaultExport from 'default-package';
import * as allExports from 'all-package';
`

const expectedCode = `
import import1 from from 'test-package/import1';
import import2 from from 'test-package/import2';
import importNested from 'test-package/nested/importNested';
import defaultExport from 'default-package'
import * as allExports from 'all-package'
`

it("should transform imports", async () => {
const result = await transformImports(code);
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import * as fs from 'node:fs';
import * as path from 'pathe';
import { describe, expect, it, vi } from 'vitest';

import { createContext } from '../../src/core/context';
import { generateImportStatement, generateImportTuple, transformImports, updateCode } from '../../src/core/transform';
import { getVersion } from '../../src/core/version';
import { ImportTuple } from '../../src/types';

vi.mock('../../src/core/version.ts');

describe('Transform', () => {
const code = fs.readFileSync(path.join(process.cwd(), 'tests/fixtures/basic.ts'), 'utf8');

const expectedCode = `import map from 'lodash/map';
import LodashMerge from 'lodash/merge'
import colors from 'picocolors'
import UnderMap from 'underscore/map'
const testMap = map([1, 2, 3], x => x + 1);
const testMerge = LodashMerge({ a: 1 }, { b: 2 });
const testColors = colors.bold('test');
const testMap2 = Undermap([1, 2, 3], x => x + 1);
export { testColors, testMap, testMap2, testMerge };`;

describe('Generate import tuples', () => {
it('maps one to one', () => {
const tuple = generateImportTuple('map');
expect(tuple).toEqual(['map', 'map']);
});

it('splits as statement', () => {
const tuple = generateImportTuple('merge as LodashMerge');
expect(tuple).toEqual(['merge', 'LodashMerge']);
});
});

const modules = [
{ module: 'lodash', transform: (moduleName: string, importName: string) => `${moduleName}/${importName}` },
];
const ctx = createContext({ modules, cwd: 'tests/fixtures' });
vi.mocked(getVersion).mockResolvedValue('2.0.0');

describe('Generating new import statements', () => {
it('should generate import statements with one to one tuple', async () => {
const tuple = ['map', 'map'] as ImportTuple;
const imports = await generateImportStatement('lodash', tuple, ctx.modules.get('lodash')!, ctx);
expect(imports).toEqual("import map from 'https://cdn.jsdelivr.net/npm/[email protected]/map/+esm';");
});

it('should generate import statements with renamed import', async () => {
const tuple = ['merge', 'LodashMerge'] as ImportTuple;
const imports = await generateImportStatement('lodash', tuple, ctx.modules.get('lodash')!, ctx);
expect(imports).toEqual("import LodashMerge from 'https://cdn.jsdelivr.net/npm/[email protected]/merge/+esm';");
});
});

it.skip('updates code with new statements', () => {
const newCode = updateCode(code, [['import import1 from \'https://cdn.jsdelivr.net/npm/test-package/[email protected]/+esm\';', 0, 59]]);

expect(newCode).toEqual(expectedCode);
});


it.skip('should transform imports', async () => {
const result = await transformImports(code, ctx);
expect(result).toEqual(expectedCode);
})
})
});
});
47 changes: 24 additions & 23 deletions tests/rollup.test.ts
Original file line number Diff line number Diff line change
@@ -1,45 +1,46 @@
import * as path from "pathe"
import { rollup } from "rollup"
import { describe, expect, it } from "vitest"
import * as path from 'pathe';
import { rollup } from 'rollup';
import { describe, expect, it } from 'vitest';

import jsDelivr from '../src/rollup'
import jsDelivr from '../src/rollup';

describe('Rollup build', () => {
describe.skip("No transform", () => {
describe.skip('Rollup build', () => {
describe.skip('No transform', () => {
it('should rewrite imports for basic', async () => {
const bundle = await rollup({
input: "./tests/fixtures/basic.ts",
input: './tests/fixtures/basic.ts',
plugins: [jsDelivr({ cwd: path.join(process.cwd(), 'tests/fixtures'), modules: [{ module: 'lodash' }, { module: 'underscore' }] })]
})
const { output } = await bundle.generate({ format: 'esm' })
expect(output[0].imports).toEqual(['https://cdn.jsdelivr.net/npm/[email protected]/+esm', 'picocolors', 'https://cdn.jsdelivr.net/npm/[email protected]/+esm'])
});
const { output } = await bundle.generate({ format: 'esm' });
expect(output[0].imports).toEqual(['https://cdn.jsdelivr.net/npm/[email protected]/+esm', 'picocolors', 'https://cdn.jsdelivr.net/npm/[email protected]/+esm']);

})
});

it('should skip rewrite imports for basic', async () => {
const bundle = await rollup({
input: './tests/fixtures/basic.ts',
plugins: [jsDelivr({ cwd: path.join(process.cwd(), 'tests/fixtures') })]
})
const { output } = await bundle.generate({ format: 'esm' })
expect(output[0].imports).toEqual(['lodash', 'picocolors', 'underscore'])
})
})
});
const { output } = await bundle.generate({ format: 'esm' });
expect(output[0].imports).toEqual(['lodash', 'picocolors', 'underscore']);
});
});

describe("With transform", () => {
describe('With transform', () => {
it('should split imports and rewrite for basic', async () => {
const bundle = await rollup({
input: "./tests/fixtures/basic.ts",
input: './tests/fixtures/basic.ts',
plugins: [jsDelivr({
cwd: path.join(process.cwd(), 'tests/fixtures'), modules: [{
module: 'lodash', transform: (moduleName, importName) => `${moduleName}/${importName}`
}]
})]
})
const { output } = await bundle.generate({ format: 'esm' })
});
const { output } = await bundle.generate({ format: 'esm' });
console.log(output);
// console.log(output)
// expect(output[0].imports).toEqual(['https://cdn.jsdelivr.net/npm/[email protected]/+esm'])
})
})
});
});

})
});

0 comments on commit b7256df

Please sign in to comment.