web-component-analyzer
Version:
CLI that analyzes web components
540 lines (528 loc) • 19 kB
JavaScript
var yargs = require('yargs');
var fs = require('fs');
var path = require('path');
var transformAnalyzerResult = require('./chunk-transform-analyzer-result-jzWAvD19.js');
var fastGlob = require('fast-glob');
var tsModule = require('typescript');
require('ts-simple-type');
function _interopNamespaceDefault(e) {
var n = Object.create(null);
if (e) {
Object.keys(e).forEach(function (k) {
if (k !== 'default') {
var d = Object.getOwnPropertyDescriptor(e, k);
Object.defineProperty(n, k, d.get ? d : {
enumerable: true,
get: function () { return e[k]; }
});
}
});
}
n.default = e;
return Object.freeze(n);
}
var yargs__namespace = /*#__PURE__*/_interopNamespaceDefault(yargs);
/**
* The most general version of compiler options.
*/
const defaultOptions = {
noEmitOnError: false,
allowJs: true,
maxNodeModuleJsDepth: 3,
experimentalDecorators: true,
target: tsModule.ScriptTarget.Latest,
downlevelIteration: true,
module: tsModule.ModuleKind.ESNext,
//module: ModuleKind.CommonJS,
//lib: ["ESNext", "DOM", "DOM.Iterable"],
strictNullChecks: true,
moduleResolution: tsModule.ModuleResolutionKind.NodeJs,
esModuleInterop: true,
noEmit: true,
allowSyntheticDefaultImports: true,
allowUnreachableCode: true,
allowUnusedLabels: true,
skipLibCheck: true
};
/**
* Compiles an array of file paths using typescript.
* @param filePaths
* @param options
*/
function compileTypescript(filePaths, options = defaultOptions) {
filePaths = Array.isArray(filePaths) ? filePaths : [filePaths];
const program = tsModule.createProgram(filePaths, options);
const files = program
.getSourceFiles()
.filter(sf => filePaths.includes(sf.fileName))
.sort((sfA, sfB) => (sfA.fileName > sfB.fileName ? 1 : -1));
return { program, files };
}
/**
* Logs to the console with a specific level.
* This function takes the config into account
* @param text
* @param config
* @param level
*/
function log(text, config, level = "normal") {
// Never log if silent
if (config.silent) {
return;
}
// Never log verbose if verbose is not on
if (level === "verbose" && !config.verbose) {
return;
}
// "unpack" function
if (typeof text === "function") {
text = text();
}
// eslint-disable-next-line no-console
if (typeof text === "object") {
// eslint-disable-next-line no-console
console.dir(text, { depth: 10 });
}
else {
// eslint-disable-next-line no-console
console.log(text);
}
}
/**
* Logs only if verbose is set to true in the config
* @param text
* @param config
*/
function logVerbose(text, config) {
log(text, config, "verbose");
}
const IGNORE_GLOBS = ["**/node_modules/**", "**/web_modules/**"];
const DEFAULT_DIR_GLOB = "**/*.{js,jsx,ts,tsx}";
const DEFAULT_GLOBS = [DEFAULT_DIR_GLOB];
/**
* Parses and analyses all globs and calls some callbacks while doing it.
* @param globs
* @param config
* @param context
*/
async function analyzeGlobs(globs, config, context = {}) {
var _a, _b, _c;
// Set default glob
if (globs.length === 0) {
globs = DEFAULT_GLOBS;
}
// Expand the globs
const filePaths = await expandGlobs(globs, config);
logVerbose(() => filePaths, config);
// Callbacks
(_a = context.didExpandGlobs) === null || _a === void 0 ? void 0 : _a.call(context, filePaths);
(_b = context.willAnalyzeFiles) === null || _b === void 0 ? void 0 : _b.call(context, filePaths);
// Parse all the files with typescript
const { program, files } = compileTypescript(filePaths);
// Analyze each file with web component analyzer
const results = [];
for (const file of files) {
// Analyze
const result = transformAnalyzerResult.analyzeSourceFile(file, {
program,
verbose: config.verbose || false,
ts: config.ts,
config: {
features: config.features,
analyzeDependencies: config.analyzeDependencies,
analyzeDefaultLib: config.analyzeDefaultLibrary,
analyzeGlobalFeatures: config.analyzeGlobalFeatures,
analyzeAllDeclarations: config.format == "json2" // TODO: find a better way to construct the config
}
});
logVerbose(() => transformAnalyzerResult.stripTypescriptValues(result, program.getTypeChecker()), config);
// Callback
await ((_c = context.emitAnalyzedFile) === null || _c === void 0 ? void 0 : _c.call(context, file, result, { program }));
results.push(result);
}
return { program, files, results };
}
/**
* Expands the globs.
* @param globs
* @param config
*/
async function expandGlobs(globs, config) {
globs = Array.isArray(globs) ? globs : [globs];
const ignoreGlobs = (config === null || config === void 0 ? void 0 : config.discoverNodeModules) ? [] : IGNORE_GLOBS;
return transformAnalyzerResult.arrayFlat(await Promise.all(globs.map(g => {
try {
// Test if the glob points to a directory.
// If so, return the result of a new glob that searches for files in the directory excluding node_modules..
const dirExists = fs.existsSync(g) && fs.lstatSync(g).isDirectory();
if (dirExists) {
return fastGlob([fastGlobNormalize(`${g}/${DEFAULT_DIR_GLOB}`)], {
ignore: ignoreGlobs,
absolute: true,
followSymbolicLinks: false
});
}
}
catch (e) {
// the glob wasn't a directory
}
// Return the result of globbing
return fastGlob([fastGlobNormalize(g)], {
ignore: ignoreGlobs,
absolute: true,
followSymbolicLinks: false
});
})));
}
/**
* Fast glob recommends normalizing paths for windows, because fast glob expects a Unix-style path.
* Read more here: https://github.com/mrmlnc/fast-glob#how-to-write-patterns-on-windows
* @param glob
*/
function fastGlobNormalize(glob) {
return glob.replace(/\\/g, "/");
}
const ERROR_NAME = "CLIError";
/**
* Make an error of kind "CLIError"
* Use this function instead of subclassing Error because of problems after transpilation.
* @param message
*/
function makeCliError(message) {
const error = new Error(message);
error.name = ERROR_NAME;
return error;
}
/**
* Returns if an error is of kind "CLIError"
* @param error
*/
function isCliError(error) {
return error instanceof Error && error.name === ERROR_NAME;
}
function ensureDirSync(dir) {
try {
fs.mkdirSync(dir, { recursive: true });
}
catch (err) {
if (err.code !== "EEXIST")
throw err;
}
}
/**
* Runs the analyze cli command.
* @param config
*/
const analyzeCliCommand = async (config) => {
var _a;
const inputGlobs = config.glob || [];
// Log warning for experimental json format
if (config.format === "json" || config.format === "json2" || ((_a = config.outFile) === null || _a === void 0 ? void 0 : _a.endsWith(".json"))) {
log(`
!!!!!!!!!!!!! WARNING !!!!!!!!!!!!!
The custom-elements.json format is for experimental purposes. You can expect changes to this format.
Please follow and contribute to the discussion at:
- https://github.com/webcomponents/custom-elements-json
- https://github.com/w3c/webcomponents/issues/776
!!!!!!!!!!!!! WARNING !!!!!!!!!!!!!
`, config);
}
// If no "out" is specified, output to console
const outStrategy = (() => {
if (config.outDir == null && config.outFile == null && config.outFiles == null) {
switch (config.format) {
case "json2":
// "json2" will need to output everything at once
return "console_bulk";
default:
return "console_stream";
}
}
return "file";
})();
// Give this context to the analyzer
const context = {
didExpandGlobs(filePaths) {
if (filePaths.length === 0) {
throw makeCliError(`Couldn't find any files to analyze.`);
}
},
willAnalyzeFiles(filePaths) {
log(`Web Component Analyzer analyzing ${filePaths.length} file${filePaths.length === 1 ? "" : "s"}...`, config);
},
emitAnalyzedFile(file, result, { program }) {
// Emit the transformed results as soon as possible if "outConsole" is on
if (outStrategy === "console_stream") {
if (result.componentDefinitions.length > 0) {
// Always use "console.log" when outputting the results
/* eslint-disable-next-line no-console */
console.log(transformResults(result, program, { ...config, cwd: config.cwd || process.cwd() }));
}
}
}
};
// Analyze, - all the magic happens in here
const { results, program } = await analyzeGlobs(inputGlobs, config, context);
const filteredResults = results.filter(result => { var _a; return result.componentDefinitions.length > 0 || result.globalFeatures != null || (((_a = result.declarations) === null || _a === void 0 ? void 0 : _a.length) || 0) > 0; });
// Write files to the file system
if (outStrategy === "console_bulk") {
// Always use "console.log" when outputting the results
/* eslint-disable-next-line no-console */
console.log(transformResults(filteredResults, program, { ...config, cwd: config.cwd || process.cwd() }));
}
else if (outStrategy === "file") {
// Build up a map of "filePath => result[]"
const outputResultMap = await distributeResultsIntoFiles(filteredResults, config);
// Write all results to corresponding paths
for (const [outputPath, results] of outputResultMap) {
if (outputPath != null) {
if (config.dry) {
const tagNames = transformAnalyzerResult.arrayFlat(results.map(result => result.componentDefinitions.map(d => d.tagName)));
log(`[dry] Intending to write ${tagNames} to ./${path.relative(process.cwd(), outputPath)}`, config);
}
else {
const content = transformResults(results, program, { ...config, cwd: config.cwd || path.dirname(outputPath) });
ensureDirSync(path.dirname(outputPath));
fs.writeFileSync(outputPath, content);
}
}
}
}
};
/**
* Transforms analyze results based on the wca cli config.
* @param results
* @param program
* @param config
*/
function transformResults(results, program, config) {
var _a, _b;
results = Array.isArray(results) ? results : [results];
// Default format is "markdown"
const format = config.format || "markdown";
const transformerConfig = {
inlineTypes: (_a = config.inlineTypes) !== null && _a !== void 0 ? _a : false,
visibility: (_b = config.visibility) !== null && _b !== void 0 ? _b : "public",
markdown: config.markdown,
cwd: config.cwd
};
return transformAnalyzerResult.transformAnalyzerResult(format, results, program, transformerConfig);
}
/**
* Analyzes input globs and returns the transformed result.
* @param inputGlobs
* @param config
*/
async function analyzeAndTransformGlobs(inputGlobs, config) {
const { results, program } = await analyzeGlobs(Array.isArray(inputGlobs) ? inputGlobs : [inputGlobs], config);
return transformResults(results, program, config);
}
/**
* Distribute results into files and return a map of "path => results"
* @param results
* @param config
*/
async function distributeResultsIntoFiles(results, config) {
const outputPathToResultMap = new Map();
// Helper function to add a result to a path. It will merge into existing results.
const addToOutputPath = (path, result) => {
const existing = outputPathToResultMap.get(path) || [];
existing.push(result);
outputPathToResultMap.set(path, existing);
};
// Output files into directory
if (config.outDir != null) {
// Get extension name based on the specified format.
const extName = formatToExtension(config.format || "markdown");
for (const result of results) {
// Write file to disc for each analyzed file
const definition = result.componentDefinitions[0];
if (definition == null)
continue;
// The name of the file becomes the tagName of the first component definition in the file.
const path$1 = path.resolve(process.cwd(), config.outDir, `${definition.tagName}${extName}`);
addToOutputPath(path$1, result);
}
}
// Output all results into a single file
else if (config.outFile != null) {
// Guess format based on outFile extension
// eslint-disable-next-line require-atomic-updates
config.format = config.format || extensionToFormat(config.outFile);
const path$1 = path.resolve(process.cwd(), config.outFile);
for (const result of results) {
addToOutputPath(path$1, result);
}
}
// Output all results into multiple files
else if (config.outFiles != null) {
// Guess format based on outFile extension
// eslint-disable-next-line require-atomic-updates
config.format = config.format || extensionToFormat(config.outFiles);
for (const result of results) {
const dir = path.relative(process.cwd(), path.dirname(result.sourceFile.fileName));
const filename = path.relative(process.cwd(), path.basename(result.sourceFile.fileName, path.extname(result.sourceFile.fileName)));
for (const definition of result.componentDefinitions) {
// The name of the file becomes the tagName of the first component definition in the file.
const path$1 = path.resolve(process.cwd(), config
.outFiles.replace(/{dir}/g, dir)
.replace(/{filename}/g, filename)
.replace(/{tagname}/g, definition.tagName));
//const path = resolve(process.cwd(), config.outFiles!, definition.tagName);
addToOutputPath(path$1, {
sourceFile: result.sourceFile,
componentDefinitions: [definition]
});
}
}
}
// Not "out" was specified. Add results to the special key "null"
else {
outputPathToResultMap.set(null, results);
}
return outputPathToResultMap;
}
/**
* Returns an extension based on a format
* @param kind
*/
function formatToExtension(kind) {
switch (kind) {
case "json":
case "vscode":
return ".json";
case "md":
case "markdown":
return ".md";
default:
return ".txt";
}
}
/**
* Returns a format based on an extension
* @param path
*/
function extensionToFormat(path$1) {
const extName = path.extname(path$1);
switch (extName) {
case ".json":
return "json";
case ".md":
return "markdown";
default:
return "markdown";
}
}
/**
* The main function of the cli.
*/
function cli() {
const argv = yargs__namespace
.usage("Usage: $0 <command> [glob..] [options]")
.command({
command: ["analyze [glob..]", "$0"],
describe: "Analyses components and emits results in a specified format.",
handler: async (config) => {
try {
await analyzeCliCommand(config);
}
catch (e) {
if (isCliError(e)) {
log(e.message, config);
}
else {
throw e;
}
}
}
})
.example(`$ $0 analyze`, "")
.example(`$ $0 analyze src --format markdown`, "")
.example(`$ $0 analyze "src/**/*.{js,ts}" --outDir output`, "")
.example(`$ $0 analyze my-element.js --outFile custom-elements.json`, "")
.example(`$ $0 analyze --outFiles {dir}/custom-element.json`, "")
.option("outDir", {
describe: `Output to a directory where each file corresponds to a web component`,
nargs: 1,
string: true
})
.option("outFile", {
describe: `Concatenate and emit output to a single file`,
nargs: 1,
string: true
})
.option("outFiles", {
describe: `Emit output to multiple files using a pattern. Available substitutions:
o {dir}: The directory of the component
o {filename}: The filename (without ext) of the component
o {tagname}: The element's tag name`,
nargs: 1,
string: true
})
.option("format", {
describe: `Specify output format`,
choices: ["md", "markdown", "json", "json2", "vscode"],
nargs: 1,
alias: "f"
})
.option("features", {
describe: `Features to enable`,
array: true,
choices: ["member", "method", "cssproperty", "csspart", "event", "slot"]
})
.option("analyzeDefaultLibrary", {
boolean: true,
hidden: true
})
.option("analyzeDependencies", {
boolean: true,
hidden: true
})
.option("discoverNodeModules", {
boolean: true,
hidden: true
})
.option("visibility", {
describe: `Minimum visibility`,
choices: ["private", "protected", "public"]
})
.option("inlineTypes", {
describe: `Inline type aliases`,
boolean: true
})
.option("dry", {
describe: `Don't write files`,
boolean: true,
alias: "d"
})
.option("verbose", {
boolean: true,
hidden: true
})
.option("silent", {
boolean: true,
hidden: true
})
// This options makes it possible to use "markdown.<sub-option>" in "strict mode"
.option("markdown", {
hidden: true
})
// This option makes it possible to specify a base cwd to use when emitting paths
.option("cwd", {
string: true,
hidden: true
})
.alias("v", "version")
.help("h")
.wrap(110)
.strict()
.alias("h", "help").argv;
if (argv.verbose) {
/* eslint-disable-next-line no-console */
console.log("CLI options:", argv);
}
}
exports.analyzeAndTransformGlobs = analyzeAndTransformGlobs;
exports.analyzeCliCommand = analyzeCliCommand;
exports.cli = cli;
;