Skip to content

Commit

Permalink
feat: Added Plugin Package Configuration + parseAllJsDoc (closes #134 c…
Browse files Browse the repository at this point in the history
…loses #133)
  • Loading branch information
nonara committed Dec 5, 2023
1 parent 53bc89d commit 15570d0
Show file tree
Hide file tree
Showing 7 changed files with 281 additions and 159 deletions.
16 changes: 16 additions & 0 deletions projects/core/shared/plugin-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,3 +138,19 @@ export type RawPattern = (
) => ts.Transformer<ts.SourceFile>;

// endregion

/* ****************************************************************************************************************** */
// region: Plugin Package
/* ****************************************************************************************************************** */

export interface PluginPackageConfig {
tscOptions?: {
/**
* Sets the JSDocParsingMode to ParseAll
* @default false
*/
parseAllJsDoc?: boolean;
}
}

// endregion
77 changes: 43 additions & 34 deletions projects/patch/src/plugin/plugin-creator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,18 @@ namespace tsp {
ls?: tsShim.LanguageService;
}

export namespace PluginCreator {
export interface Options {
resolveBaseDir: string;
}
}

// endregion

/* ********************************************************* */
// region: Helpers
/* ********************************************************* */

function validateConfigs(configs: PluginConfig[]) {
for (const config of configs)
if (!config.name && !config.transform) throw new TsPatchError('tsconfig.json plugins error: transform must be present');
}

function createTransformerFromPattern(opt: CreateTransformerFromPatternOptions): TransformerBasePlugin {
const { factory, config, program, ls, registerConfig } = opt;
const { transform, after, afterDeclarations, name, type, transformProgram, ...cleanConfig } = config;
Expand Down Expand Up @@ -131,49 +132,55 @@ namespace tsp {
* PluginCreator (Class)
* ********************************************************* */

/**
* @example
*
* new PluginCreator([
* {transform: '@zerollup/ts-transform-paths', someOption: '123'},
* {transform: '@zerollup/ts-transform-paths', type: 'ls', someOption: '123'},
* {transform: '@zerollup/ts-transform-paths', type: 'ls', after: true, someOption: '123'}
* ]).createTransformers({ program })
*/
export class PluginCreator {
constructor(
private configs: PluginConfig[],
public resolveBaseDir: string = process.cwd()
)
{
validateConfigs(configs);
public readonly plugins: TspPlugin[] = [];
public readonly options: PluginCreator.Options;
public readonly needsTscJsDocParsing: boolean;

private readonly configs: PluginConfig[];

constructor(configs: PluginConfig[], options: PluginCreator.Options) {
this.configs = configs;
this.options = options;

const { resolveBaseDir } = options;

/* Create plugins */
this.plugins = configs.map(config => new TspPlugin(config, { resolveBaseDir }));

/* Check if we need to parse all JSDoc comments */
this.needsTscJsDocParsing = this.plugins.some(plugin => plugin.packageConfig?.tscOptions?.parseAllJsDoc === true);
}

public mergeTransformers(into: TransformerList, source: tsShim.CustomTransformers | TransformerBasePlugin) {
private mergeTransformers(into: TransformerList, source: tsShim.CustomTransformers | TransformerBasePlugin) {
const slice = <T>(input: T | T[]) => (Array.isArray(input) ? input.slice() : [ input ]);

// TODO : Consider making this optional https://github.com/nonara/ts-patch/issues/122

if (source.before) into.before.push(...slice(source.before));
if (source.after) into.after.push(...slice(source.after));
if (source.afterDeclarations) into.afterDeclarations.push(...slice(source.afterDeclarations));

return this;
}

public createTransformers(
public createSourceTransformers(
params: { program: tsShim.Program } | { ls: tsShim.LanguageService },
customTransformers?: tsShim.CustomTransformers
): TransformerList {
const transformers: TransformerList = { before: [], after: [], afterDeclarations: [] };

const [ ls, program ] = ('ls' in params) ? [ params.ls, params.ls.getProgram()! ] : [ void 0, params.program ];

for (const config of this.configs) {
if (!config.transform || config.transformProgram) continue;
for (const plugin of this.plugins) {
if (plugin.kind !== 'SourceTransformer') continue;

const { config } = plugin;

const resolvedFactory = tsp.resolveFactory(this, config);
if (!resolvedFactory) continue;
const createFactoryResult = plugin.createFactory();
if (!createFactoryResult) continue;

const { factory, registerConfig } = resolvedFactory;
const { factory, registerConfig } = createFactoryResult;

this.mergeTransformers(
transformers,
Expand All @@ -193,16 +200,18 @@ namespace tsp {
return transformers;
}

public getProgramTransformers(): Map<string, [ ProgramTransformer, PluginConfig ]> {
public createProgramTransformers(): Map<string, [ ProgramTransformer, PluginConfig ]> {
const res = new Map<string, [ ProgramTransformer, PluginConfig ]>();
for (const config of this.configs) {
if (!config.transform || !config.transformProgram) continue;
for (const plugin of this.plugins) {
if (plugin.kind !== 'ProgramTransformer') continue;

const { config } = plugin;

const resolvedFactory = resolveFactory(this, config);
if (resolvedFactory === undefined) continue;
const createFactoryResult = plugin.createFactory();
if (createFactoryResult === undefined) continue;

const { registerConfig } = resolvedFactory;
const factory = wrapTransformer(resolvedFactory.factory as ProgramTransformer, registerConfig, false);
const { registerConfig, factory: unwrappedFactory } = createFactoryResult;
const factory = wrapTransformer(unwrappedFactory as ProgramTransformer, registerConfig, false);

const transformerKey = crypto
.createHash('md5')
Expand Down
205 changes: 205 additions & 0 deletions projects/patch/src/plugin/plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
namespace tsp {
const path = require('path');
const fs = require('fs');

const requireStack: string[] = [];

/* ****************************************************** */
// region: Types
/* ****************************************************** */

export namespace TspPlugin {
export interface CreateOptions {
resolveBaseDir: string
}

export type Kind = 'SourceTransformer' | 'ProgramTransformer'
}

// endregion

/* ****************************************************** */
// region: Helpers
/* ****************************************************** */

function getModulePackagePath(transformerPath: string, resolveBaseDir: string): string | undefined {
let transformerPackagePath: string | undefined;
try {
const pathQuery = path.join(transformerPath, 'package.json');
transformerPackagePath = path.normalize(require.resolve(pathQuery, { paths: [ resolveBaseDir ] }));
} catch (e) {
return undefined;
}

let currentDir = path.dirname(transformerPath);

const seenPaths = new Set<string>();
while (currentDir !== path.parse(currentDir).root) {
if (seenPaths.has(currentDir)) return undefined;
seenPaths.add(currentDir);

// Could likely fail if the transformer is in a symlinked directory or the package's main file is in a
// directory above the package.json – however, I believe that the walking up method used here is the common
// approach, so we'll consider these acceptable edge cases for now.
if (path.relative(currentDir, transformerPackagePath).startsWith('..')) return undefined;

const potentialPkgPath = path.join(currentDir, 'package.json');
if (fs.existsSync(potentialPkgPath)) {
if (potentialPkgPath === transformerPackagePath) return transformerPackagePath;
return undefined;
}

currentDir = path.resolve(currentDir, '..');
}

return undefined;
}

// endregion

/* ****************************************************** */
// region: TspPlugin
/* ****************************************************** */

export class TspPlugin {
public readonly config: PluginConfig;
public readonly tsConfigPath: string | undefined;
public readonly entryFilePath: string;
public readonly importKey: string;
public readonly packageConfig: PluginPackageConfig | undefined;
public readonly kind: TspPlugin.Kind;

private readonly _createOptions: TspPlugin.CreateOptions;

constructor(config: PluginConfig, createOptions: TspPlugin.CreateOptions) {
this.config = { ...config };
this.validateConfig();

this._createOptions = createOptions;
this.importKey = config.import || 'default';
this.kind = config.transformProgram === true ? 'ProgramTransformer' : 'SourceTransformer';

const { resolveBaseDir } = createOptions;
const configTransformValue = config.transform!;

/* Resolve paths */
this.tsConfigPath = config.tsConfig && path.resolve(resolveBaseDir, config.tsConfig);
const entryFilePath = require.resolve(configTransformValue, { paths: [ resolveBaseDir ] });
this.entryFilePath = entryFilePath;

/* Get module PluginPackageConfig */
const modulePackagePath = getModulePackagePath(entryFilePath, resolveBaseDir);
let pluginPackageConfig: PluginPackageConfig | undefined;
if (modulePackagePath) {
const modulePkgJsonContent = fs.readFileSync(modulePackagePath, 'utf8');
const modulePkgJson = JSON.parse(modulePkgJsonContent) as { tsp?: PluginPackageConfig };

pluginPackageConfig = modulePkgJson.tsp;
if (pluginPackageConfig === null || typeof pluginPackageConfig !== 'object') pluginPackageConfig = undefined;
}

this.packageConfig = pluginPackageConfig;
}

private validateConfig() {
const { config } = this;

const configTransformValue = config.transform;
if (!configTransformValue) throw new TsPatchError(`Invalid plugin config: missing "transform" value`);

if (config.resolvePathAliases && !config.tsConfig) {
console.warn(`[ts-patch] Warning: resolvePathAliases needs a tsConfig value pointing to a tsconfig.json for transformer" ${configTransformValue}.`);
}
}

createFactory() {
const { entryFilePath, config, tsConfigPath, importKey } = this;
const configTransformValue = config.transform!;

/* Prevent circular require */
if (requireStack.includes(entryFilePath)) return;
requireStack.push(entryFilePath);

/* Check if ESM */
let isEsm: boolean | undefined = config.isEsm;
if (isEsm == null) {
const impliedModuleFormat = tsShim.getImpliedNodeFormatForFile(
entryFilePath as tsShim.Path,
undefined,
tsShim.sys,
{ moduleResolution: tsShim.ModuleResolutionKind.Node16 }
);

isEsm = impliedModuleFormat === tsShim.ModuleKind.ESNext;
}

const isTs = configTransformValue.match(/\.[mc]?ts$/) != null;

const registerConfig: RegisterConfig = {
isTs,
isEsm,
tsConfig: tsConfigPath,
pluginConfig: config
};

registerPlugin(registerConfig);

try {
/* Load plugin */
const commonjsModule = loadEntryFile();

const factoryModule = (typeof commonjsModule === 'function') ? { default: commonjsModule } : commonjsModule;
const factory = factoryModule[importKey];

if (!factory)
throw new TsPatchError(
`tsconfig.json > plugins: "${configTransformValue}" does not have an export "${importKey}": ` +
require('util').inspect(factoryModule)
);

if (typeof factory !== 'function') {
throw new TsPatchError(
`tsconfig.json > plugins: "${configTransformValue}" export "${importKey}" is not a plugin: ` +
require('util').inspect(factory)
);
}

return {
factory,
registerConfig: registerConfig
};
}
finally {
requireStack.pop();
unregisterPlugin();
}

function loadEntryFile(): PluginFactory | { [key: string]: PluginFactory } {
/* Load plugin */
let res: PluginFactory | { [key: string]: PluginFactory }
try {
res = require(entryFilePath);
} catch (e) {
if (e.code === 'ERR_REQUIRE_ESM') {
if (!registerConfig.isEsm) {
unregisterPlugin();
registerConfig.isEsm = true;
registerPlugin(registerConfig);
return loadEntryFile();
} else {
throw new TsPatchError(
`Cannot load ESM transformer "${configTransformValue}" from "${entryFilePath}". Please file a bug report`
);
}
}
else throw e;
}
return res;
}
}
}

// endregion
}

// endregion

0 comments on commit 15570d0

Please sign in to comment.