UNPKG

web-component-analyzer

Version:
540 lines (528 loc) 19 kB
'use strict'; 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;