Skip to content

Commit

Permalink
feat(RootScan): ensure RootScan also scans module fields in package…
Browse files Browse the repository at this point in the history
….json (#369)

* feat(RootScan): ensure RootScan also scans `module` fields in package.json
* docs: add copilot generated tsdoc
* refactor: process review and cleanup code
  • Loading branch information
favna committed Dec 27, 2023
1 parent 747a6f2 commit 7eb28a9
Show file tree
Hide file tree
Showing 15 changed files with 1,098 additions and 29 deletions.
30 changes: 18 additions & 12 deletions .github/workflows/continuous-integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,12 @@ jobs:
- name: Run ESLint
run: yarn lint --fix=false

Building:
name: Compile source code
docs:
name: Generate Documentation
runs-on: ubuntu-latest
steps:
- name: Checkout Project
uses: actions/checkout@v4
- name: Add problem matcher
run: echo "::add-matcher::.github/problemMatchers/tsc.json"
- name: Use Node.js v20
uses: actions/setup-node@v4
with:
Expand All @@ -42,22 +40,30 @@ jobs:
registry-url: https://registry.npmjs.org/
- name: Install Dependencies
run: yarn --immutable
- name: Build Code
run: yarn build
- name: Generate Documentation
run: yarn docs

docs:
name: Generate Documentation
BuildingAndTesting:
name: Building and Testing with node v${{ matrix.node }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node: [18, 19, 20, 21]
steps:
- name: Checkout Project
uses: actions/checkout@v4
- name: Use Node.js v20
- name: Add problem matcher
run: echo "::add-matcher::.github/problemMatchers/tsc.json"
- name: Use Node.js ${{ matrix.node }}
uses: actions/setup-node@v4
with:
node-version: 20
node-version: ${{ matrix.node }}
cache: yarn
registry-url: https://registry.npmjs.org/
- name: Install Dependencies
run: yarn --immutable
- name: Generate Documentation
run: yarn docs
- name: Typecheck And Build Code
run: yarn typecheck && yarn build
- name: Run tests
run: yarn test
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"docs": "typedoc-json-parser",
"update": "yarn upgrade-interactive",
"clean": "rimraf dist",
"test": "vitest run",
"build": "tsup && concurrently \"yarn:postbuild:*\"",
"postbuild:internal": "node scripts/make-import.mjs",
"postbuild:types:cjs": "rollup-type-bundler -d dist/cjs",
Expand Down Expand Up @@ -61,7 +62,8 @@
"tsup": "^8.0.1",
"typedoc": "^0.25.4",
"typedoc-json-parser": "^9.0.1",
"typescript": "^5.3.3"
"typescript": "^5.3.3",
"vitest": "^1.1.0"
},
"repository": {
"type": "git",
Expand Down
105 changes: 95 additions & 10 deletions src/lib/internal/RootScan.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,108 @@
import { readFileSync } from 'fs';
import { dirname, join } from 'path';

/**
* Represents a partial package.json object.
*/
type PartialPackageJson = Partial<{
main: string;
module: string;
type: 'commonjs' | 'module';
}>;

/**
* Represents the root data.
*/
export interface RootData {
/**
* The root directory.
*/
root: string;

/**
* The type of the module system used.
* It can be either 'ESM' or 'CommonJS'.
*/
type: 'ESM' | 'CommonJS';
}

let data: RootData | null = null;

/**
* Returns the directory name of a given path by joining the current working directory (cwd) with the joinable path.
* @private
* @param cwd - The current working directory.
* @param joinablePath - The path to be joined with the cwd.
* @returns The directory name of the joined path.
*/
function dirnameWithPath(cwd: string, joinablePath: string) {
return dirname(join(cwd, joinablePath));
}

export function getRootData(): RootData {
if (data !== null) return data;
return (data ??= parseRootData());
}

/**
* Retrieves the root data of the project.
*
* This function reads the `package.json` file in the current working directory and determines the root path and type
* of the project.
*
* - If the `package.json` file is not found or cannot be parsed, it assumes the project is using CommonJS and
* the current working directory is used as the root
*
* - If the project `type` is specified as `"commonjs"` or `"module"` in the `package.json`, it uses the corresponding
* `main` or `module` file path as the root.
*
* - If there is no `main` or `module` then it uses the current working directory as the root, while retaining the
* matching `CommonJS` or `ESM` based on the `type`
*
* - If the main or module file path is not specified, it uses the current working directory as the root.
*
* The following table shows how different situations resolve to different root data
*
* | fields | resolved as |
* |--------------------------|-------------|
* | type=commonjs && main | CommonJS |
* | type=commonjs && module | CommonJS |
* | type=module && main | ESM |
* | type=module && module | ESM |
* | type=undefined && main | CommonJS |
* | type=undefined && module | ESM |
* | no package.json on cwd | CommonJS |
*
* @returns The root data object containing the root path and the type of the project.
*/
export function parseRootData(): RootData {
const cwd = process.cwd();

let file: PartialPackageJson | undefined;

try {
const file = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf8'));
data = { root: dirname(join(cwd, file.main)), type: file.type === 'module' ? 'ESM' : 'CommonJS' };
} catch {
data = { root: cwd, type: 'CommonJS' };
file = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf8')) as PartialPackageJson;
} catch (error) {
return { root: cwd, type: 'CommonJS' };
}

return data;
}
const { main: packageMain, module: packageModule, type: packageType } = file;

export interface RootData {
root: string;
type: 'ESM' | 'CommonJS';
const lowerCasedType = packageType?.toLowerCase() as PartialPackageJson['type'];

if (lowerCasedType === 'commonjs') {
if (packageMain) return { root: dirnameWithPath(cwd, packageMain), type: 'CommonJS' };
if (packageModule) return { root: dirnameWithPath(cwd, packageModule), type: 'CommonJS' };
return { root: cwd, type: 'CommonJS' };
}

if (lowerCasedType === 'module') {
if (packageMain) return { root: dirnameWithPath(cwd, packageMain), type: 'ESM' };
if (packageModule) return { root: dirnameWithPath(cwd, packageModule), type: 'ESM' };
return { root: cwd, type: 'ESM' };
}

if (packageMain) return { root: dirnameWithPath(cwd, packageMain), type: 'CommonJS' };
if (packageModule) return { root: dirnameWithPath(cwd, packageModule), type: 'ESM' };

return { root: cwd, type: 'CommonJS' };
}
4 changes: 4 additions & 0 deletions tests/fixtures/commonjs/main/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"type": "commonjs",
"main": "dist/lib/index.js"
}
4 changes: 4 additions & 0 deletions tests/fixtures/commonjs/module/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"type": "commonjs",
"module": "dist/lib/index.js"
}
4 changes: 4 additions & 0 deletions tests/fixtures/module/main/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"type": "module",
"main": "dist/lib/index.js"
}
4 changes: 4 additions & 0 deletions tests/fixtures/module/module/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"type": "module",
"module": "dist/lib/index.js"
}
Empty file.
3 changes: 3 additions & 0 deletions tests/fixtures/no-type/main/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"main": "dist/lib/index.js"
}
3 changes: 3 additions & 0 deletions tests/fixtures/no-type/module/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"module": "dist/lib/index.js"
}
114 changes: 114 additions & 0 deletions tests/lib/internal/RootScan.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { join } from 'node:path';
import type { MockInstance } from 'vitest';
import { parseRootData, type RootData } from '../../../src/lib/internal/RootScan';

let cwd: MockInstance<[], string>;

afterEach(() => {
expect(cwd).toHaveBeenCalled();

cwd.mockRestore();
});

describe('RootScan', () => {
describe('type=commonjs', () => {
const commonJsBasePath = 'commonjs';

test('GIVEN type=commonjs && main property THEN returns correct root', () => {
mockCwd(commonJsBasePath, 'main');

const rootData = parseRootData();
expectRootDataToEqual(rootData, 'CommonJS', commonJsBasePath, 'main', 'dist', 'lib');
});

test('GIVEN type=commonjs && module property THEN returns correct root', () => {
mockCwd(commonJsBasePath, 'module');

const rootData = parseRootData();
expectRootDataToEqual(rootData, 'CommonJS', commonJsBasePath, 'module', 'dist', 'lib');
});
});

describe('type=module', () => {
const moduleBasePath = 'module';

test('GIVEN type=module && main property THEN returns correct root', () => {
mockCwd(moduleBasePath, 'main');

const rootData = parseRootData();
expectRootDataToEqual(rootData, 'ESM', moduleBasePath, 'main', 'dist', 'lib');
});

test('GIVEN type=module && module property THEN returns correct root', () => {
mockCwd(moduleBasePath, 'module');

const rootData = parseRootData();
expectRootDataToEqual(rootData, 'ESM', moduleBasePath, 'module', 'dist', 'lib');
});
});

describe('no-package', () => {
const noPackageBasePath = 'no-package';

test('GIVEN no package.json THEN returns correct root', () => {
mockCwd(noPackageBasePath);

const rootData = parseRootData();

expect(rootData).toStrictEqual<RootData>({
root: testsFixturesJoin(noPackageBasePath),
type: 'CommonJS'
});
});
});

describe('type=undefined', () => {
const noTypeBasePath = 'no-type';

test('GIVEN type=undefined && main property THEN returns correct root', () => {
mockCwd(noTypeBasePath, 'main');

const rootData = parseRootData();
expectRootDataToEqual(rootData, 'CommonJS', noTypeBasePath, 'main', 'dist', 'lib');
});

test('GIVEN type=undefined && module property THEN returns correct root', () => {
mockCwd(noTypeBasePath, 'module');

const rootData = parseRootData();
expectRootDataToEqual(rootData, 'ESM', noTypeBasePath, 'module', 'dist', 'lib');
});
});
});

/**
* Mocks the current working directory by setting the return value of `process.cwd()` to the joined path of the provided arguments.
*
* @param pathForCwd - The path segments to join and set as the current working directory.
*/
function mockCwd(...pathForCwd: string[]) {
cwd = vi.spyOn(process, 'cwd').mockReturnValue(testsFixturesJoin(...pathForCwd));
}

/**
* Asserts that the actual RootData object is equal to the expected RootData object.
* @param actual - The actual RootData object.
* @param type - The type of the RootData object ('ESM' or 'CommonJS').
* @param rootPath - The root path of the RootData object.
*/
function expectRootDataToEqual(actual: RootData, type: 'ESM' | 'CommonJS', ...rootPath: string[]) {
expect(actual).toStrictEqual<RootData>({
root: testsFixturesJoin(...rootPath),
type
});
}

/**
* Joins the given path segments with 'tests/fixtures' and returns the resulting path.
*
* @param path - The path segments to join.
* @returns The joined path.
*/
function testsFixturesJoin(...path: string[]) {
return join('tests', 'fixtures', ...path);
}
6 changes: 6 additions & 0 deletions tests/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"types": ["vitest/globals"]
}
}
6 changes: 5 additions & 1 deletion tsconfig.eslint.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
{
"extends": "./tsconfig.base.json",
"include": ["src", "scripts", "tsup.config.ts"]
"compilerOptions": {
"types": ["vitest/globals"],
"lib": ["DOM", "ESNext"]
},
"include": ["src", "tests", "scripts", "vitest.config.ts", "tsup.config.ts"]
}
7 changes: 7 additions & 0 deletions vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config';

export default defineConfig({
test: {
globals: true
}
});
Loading

0 comments on commit 7eb28a9

Please sign in to comment.