UNPKG

@azure/static-web-apps-cli

Version:
354 lines 17.2 kB
import { promises as fs } from "fs"; import globrex from "globrex"; import path from "path"; import { DATA_API_BUILDER_DEFAULT_CONFIG_FILE_NAME } from "../constants.js"; import { DEFAULT_CONFIG } from "../../config.js"; import { hasSpaces, removeTrailingPathSep } from "../utils/strings.js"; import { logger } from "../utils/logger.js"; import { safeReadFile, safeReadJson } from "../utils/file.js"; import { apiFrameworks, appFrameworks } from "./frameworks.js"; const packageJsonFile = "package.json"; export async function generateConfiguration(app, api, dataApi) { let config = { appLocation: DEFAULT_CONFIG.appLocation, outputLocation: DEFAULT_CONFIG.outputLocation, }; if (!app && !api) { logger.silly("No known frameworks detected"); return config; } let name = ""; if (app) { name += `${app.frameworks.map((f) => f.name).join(", ")}`; app.frameworks.forEach((f) => (config = { ...config, ...f.config })); config.appLocation = await computePath(app.rootPath, config.appLocation); config.appLocation = removeTrailingPathSep(config.appLocation); config.outputLocation = await computePath(config.appLocation, config.outputLocation); config.outputLocation = path.normalize(path.relative(config.appLocation, config.outputLocation)); config.outputLocation = removeTrailingPathSep(config.outputLocation); } if (api) { name += name ? ", with " : "No app frameworks detected, "; name += `API: ${api.frameworks.map((f) => f.name).join(", ")}`; api.frameworks.forEach((f) => (config = { ...config, ...f.config })); const computedApiLocation = await computePath(api.rootPath, config.apiLocation); if (computedApiLocation !== api.rootPath) { // TODO: if someday SWA introduces an equivalent to outputLocation for the API // we should handle this here logger.silly(`Built API location "${computedApiLocation}" does not match root API location ${api.rootPath}, which is not supported yet`); } config.apiLocation = removeTrailingPathSep(api.rootPath); } if (dataApi) { name += name ? ", " : "No app frameworks detected, "; name += `data API: ${dataApi.databaseType}`; config.dataApiLocation = dataApi.rootPath; } const appRootPath = app && removeTrailingPathSep(app.rootPath); if (appRootPath && config.appBuildCommand && appRootPath !== config.appLocation) { // If the final app location is not the same as the detected root path of the app, // we need to adjust the build command to run in the correct path. let commandPath = path.relative(config.appLocation, appRootPath); commandPath = hasSpaces(commandPath) ? `"${commandPath}"` : commandPath; config.appBuildCommand = `cd ${commandPath} && ${config.appBuildCommand}`; } const apiRootPath = api && removeTrailingPathSep(api.rootPath); if (apiRootPath && config.apiBuildCommand && apiRootPath !== config.apiLocation) { // If the final api location is not the same as the detected root path of the api, // we need to adjust the build command to run in the correct path. let commandPath = path.relative(config.apiLocation, apiRootPath); commandPath = hasSpaces(commandPath) ? `"${commandPath}"` : commandPath; config.apiBuildCommand = `cd ${commandPath} && ${config.apiBuildCommand}`; } config.name = name; return config; } async function computePath(basePath, additionalPath) { if (!additionalPath) { return basePath; } if (!additionalPath.startsWith("{")) { return path.join(basePath, additionalPath); } // Matches {<filename>#<expression>}, the first group is the filename, the second the expression const match = additionalPath.match(/^\{(.*?)#(.*?)\}$/); const [, filename, expression] = match || []; if (!filename || !expression) { throw new Error(`Invalid dynamic path format: ${additionalPath}`); } const files = await getFiles(basePath); const file = findFile(filename, files); if (!file) { throw new Error(`File "${filename}" not found in dynamic path: ${additionalPath}`); } const json = await safeReadJson(file); if (!json) { throw new Error(`Invalid JSON file: ${file}`); } const evaluateExpression = (json, expr) => Function(`"use strict";return data => (${expr})`)()(json); try { const result = evaluateExpression(json, expression); if (result) { return path.join(basePath, result); } } catch (error) { const err = error; logger.silly(err.stack || err.message); throw new Error(`Invalid expression "${expression}" in dynamic path: ${additionalPath}`); } return basePath; } export async function detectDbConfigFiles(projectPath = ".") { // Detect all the "staticwebapp.database.config.json" with valid "database-type" const projectFiles = await getFiles(projectPath); const dbConfigFilePaths = projectFiles.filter((f) => f.endsWith(DATA_API_BUILDER_DEFAULT_CONFIG_FILE_NAME)); let dbConfigFiles = await Promise.all(dbConfigFilePaths.map(async (f) => { const contains = await safeReadJson(f); let result = { rootPath: path.dirname(f), databaseType: contains?.["data-source"]?.["database-type"], }; return result; })); dbConfigFiles = dbConfigFiles.filter((f) => f.databaseType !== undefined); return dbConfigFiles; } export async function detectProjectFolders(projectPath = ".") { const projectFiles = await getFiles(projectPath); const apiFrameworks = await detectApiFrameworks(projectFiles); const appFrameworks = await detectAppFrameworks(projectFiles); return { app: appFrameworks, api: apiFrameworks, }; } export async function detectFrameworks(projectFiles, frameworks) { // Here's how the detection heuristic works: // 1. Find possible roots for all frameworks based on files // - All specified files must be matched // - If a framework has a "packages" property, file "package.json" must be present // - If a framework has a parent, all parent's files must be matched // 2. Filter frameworks by keeping only the ones that match packages // - If any packages in the list is found in dependencies or devDependencies, it's a match // 3. Filter frameworks by keeping only the ones that pass their "contains" test // - All files specified in "contains" must be present and contain the specified string // 4. Aggregate detection results by root path // - Build a list of potential app root paths, with the list of frameworks found for each // 5. Filter out all root paths that are descendant of other root paths // - Eliminate false-positives due to output/build artifacts or frameworks including example projects // as part of their theming or docs, within the app folder // 6. Filter out frameworks in each root path based on overrides config // - Clean up the list of frameworks, as some completely redefine the configuration and allow mix & match // of multiple other frameworks under a specific build tool (Astro, for example) // - Note that "static" framework will automatically be overriden by any other framework. // 7. Order frameworks in each root path based on parent-child relationships // - As child frameworks may extend or override their parent's configuration, we need to make sure the // parent's configuration is applied first const frameworksById = frameworks.reduce((acc, f) => ({ ...acc, [f.id]: f }), {}); let detectedFrameworks = []; for (const framework of frameworks) { const files = []; // Parent files are implicit for child frameworks if (framework.parent) { const parent = frameworksById[framework.parent]; files.push(...(parent.files ?? []), ...(parent.packages ? [packageJsonFile] : [])); } files.push(...(framework.files ?? []), ...(framework.packages ? [packageJsonFile] : [])); const rootPaths = await findRootPathsForFiles(files, projectFiles); if (rootPaths !== undefined) { detectedFrameworks.push({ ...framework, rootPaths }); } } detectedFrameworks = await asyncFilter(detectedFrameworks, async (framework) => (await matchPackages(framework)) && (await matchContains(framework, projectFiles))); let detectedFolders = await aggregateFolders(detectedFrameworks); detectedFolders = filterDescendantFolders(detectedFolders); filterPreemptedFrameworks(detectedFolders); orderFrameworksByParent(detectedFolders); return detectedFolders; } async function detectApiFrameworks(projectFiles) { const detectedApiFolders = await detectFrameworks(projectFiles, apiFrameworks); logger.silly(formatDetectedFolders(detectedApiFolders, "api")); return detectedApiFolders; } async function detectAppFrameworks(projectFiles) { const detectedAppFolders = await detectFrameworks(projectFiles, appFrameworks); logger.silly(formatDetectedFolders(detectedAppFolders, "app")); return detectedAppFolders; } async function aggregateFolders(detectedFrameworks) { const stacks = {}; for (const detectedFramework of detectedFrameworks) { for (const rootPath of detectedFramework.rootPaths) { if (!stacks[rootPath]) { stacks[rootPath] = []; } stacks[rootPath].push(detectedFramework); } } return Object.entries(stacks).map(([rootPath, frameworks]) => ({ rootPath, frameworks })); } async function matchPackages(framework) { if (!framework.packages) { return true; } const rootPathsMatches = await asyncFilter(framework.rootPaths, async (rootPath) => { const packageJsonPath = path.join(rootPath, packageJsonFile); const packageJson = await safeReadJson(packageJsonPath); if (!packageJson) { return false; } const dependencies = Object.keys(packageJson.dependencies ?? {}); const devDependencies = Object.keys(packageJson.devDependencies ?? {}); return framework.packages.some((packageName) => dependencies.includes(packageName) || devDependencies.includes(packageName)); }); framework.rootPaths = rootPathsMatches; return rootPathsMatches.length > 0; } async function matchContains(framework, files) { if (!framework.contains) { return true; } const rootPathsMatches = await asyncFilter(framework.rootPaths, async (rootPath) => { const currentFiles = filesFromRootPath(rootPath, files); return asyncEvery(Object.entries(framework.contains), async ([filename, stringToFind]) => { const file = findFile(filename, currentFiles); const content = await safeReadFile(file); if (!content) { return false; } return content.includes(stringToFind); }); }); framework.rootPaths = rootPathsMatches; return rootPathsMatches.length > 0; } export function isDescendantPath(testedPath, referencePath) { return testedPath !== referencePath && !path.relative(referencePath, testedPath).startsWith(".."); } function filterDescendantFolders(folders) { // Find all folders that are descendants of other folders const descendantPaths = new Set(); for (const folder of folders) { const descendantsFolders = folders.filter((f) => isDescendantPath(f.rootPath, folder.rootPath)); descendantsFolders.forEach((f) => descendantPaths.add(f.rootPath)); } if (descendantPaths.size === 0) { return folders; } logger.silly(`Found descendant folders to exclude:`); logger.silly(`- ${Array.from(descendantPaths).join("\n- ")}`); // Only keep folders that are not descendants return folders.filter((f) => !descendantPaths.has(f.rootPath)); } function filterPreemptedFrameworks(detectedFolders) { for (const folder of detectedFolders) { const overridenFrameworkIds = new Set(); folder.frameworks.forEach((f) => { if (f.overrides) { f.overrides.forEach((id) => overridenFrameworkIds.add(id)); } }); // Static is special: if any other app framework is detected in a folder, // then static is automatically overriden if (folder.frameworks.length > 1) { overridenFrameworkIds.add("static"); } if (overridenFrameworkIds.size === 0) { continue; } logger.silly(`Found frameworks to override in path ${folder.rootPath}: ${Array.from(overridenFrameworkIds).join(",")}`); folder.frameworks = folder.frameworks.filter((f) => !overridenFrameworkIds.has(f.id)); } } function orderFrameworksByParent(detectedFolders) { for (const folder of detectedFolders) { const { frameworks } = folder; const frameworkIndexById = {}; let currentIndex = 0; // Make sure all parent frameworks are placed before their children, leave the rest untouched for (const framework of frameworks) { if (framework.parent && frameworkIndexById[framework.parent] === undefined) { // Whoops, parent must be placed before this framework! const parentIndex = frameworks.findIndex((f) => f.id === framework.parent); if (parentIndex === -1) { // Lonely childs should not be a thing logger.silly(`Framework ${framework.id} has parent ${framework.parent} but it's not detected`); frameworks.splice(currentIndex, 1); continue; } // Put the parent before this framework frameworks.splice(currentIndex, 0, frameworks.splice(parentIndex, 1)[0]); frameworkIndexById[framework.parent] = currentIndex++; } frameworkIndexById[framework.id] = currentIndex++; } } } function findAllFiles(fileglob, files) { const { regex } = globrex(`?(*${path.sep})${fileglob}`, { extended: true, flags: "i" }); return files.filter((file) => regex.test(file)); } function findFile(fileglob, files) { return findAllFiles(fileglob, files)[0]; } function filesFromRootPath(rootPath, files) { if (rootPath === "." || rootPath === `.${path.sep}`) { return files; } return files.filter((file) => file.startsWith(rootPath)); } function findRootPathsForFiles(fileglobs, files) { const foundFiles = fileglobs.map((fileglob) => findAllFiles(fileglob, files)); // Get possible root path from first glob matches // Note: currently it doesn't work if globs include subfolders // TODO: find common path denominator based on lowest dirname ancestor to try to find common root const uniqueRootPaths = new Set(foundFiles[0].map((file) => path.dirname(file))); const otherFoundFiles = foundFiles.slice(1); const possibleRootPaths = [...uniqueRootPaths].filter((p) => otherFoundFiles.every((files) => files.some((file) => path.dirname(file) === p))); return possibleRootPaths.length > 0 ? possibleRootPaths : undefined; } async function getFiles(rootPath) { const entries = await fs.readdir(rootPath, { withFileTypes: true }); const files = await Promise.all(entries.map(async (entry) => { // Ignore dot files and node_modules if (entry.name.startsWith(".") || entry.name.includes("node_modules")) { return []; } const entryPath = path.join(rootPath, entry.name); return entry.isDirectory() ? [entryPath, ...(await getFiles(entryPath))] : [entryPath]; })); return files.flat(); } async function asyncFilter(array, predicate) { const results = await Promise.all(array.map(predicate)); return array.filter((_, index) => results[index]); } async function asyncEvery(array, predicate) { const results = await Promise.all(array.map(predicate)); return array.every((_, index) => results[index]); } export function printSupportedFrameworks(showList = false) { if (showList) { logger.info(`Supported api frameworks: ${apiFrameworks.length}`); logger.info(`- ${apiFrameworks.map((f) => f.name).join("- \n")}`); logger.info(`Supported app frameworks: ${apiFrameworks.length}`); logger.info(`- ${appFrameworks.map((f) => f.name).join("- \n")}`); } else { logger.info(`Supported frameworks:`); logger.info(`- api: ${apiFrameworks.length}`); logger.info(`- app: ${appFrameworks.length}`); } } export function formatDetectedFolders(folders, type) { return (`Detected ${type} folders (${folders.length}):\n` + `- ${folders .map((f) => `${f.rootPath} (${f.frameworks .map((fr) => fr.name) .filter((frname, index, array) => array.indexOf(frname) === index) .join(", ")})`) .join("\n- ")}`); } //# sourceMappingURL=detect.js.map