UNPKG

@greenwood/cli

Version:
303 lines (265 loc) 11.4 kB
import fs from "fs"; import { isBuiltin } from "node:module"; // priority if from L -> R const SUPPORTED_EXPORT_CONDITIONS = ["import", "module-sync", "default"]; const IMPORT_MAP_RESOLVED_PREFIX = "/~"; const importMap = new Map(); const diagnostics = new Map(); function updateImportMap(key, value, resolvedRoot) { importMap.set( key.replace("./", ""), `${IMPORT_MAP_RESOLVED_PREFIX}${resolvedRoot.replace("file://", "")}${value.replace("./", "")}`, ); } // wrapper around import.meta.resolve to provide graceful error handling / logging // as sometimes a package.json has no main field :/ // https://unpkg.com/browse/@types/trusted-types@2.0.7/package.json // https://github.com/nodejs/node/issues/49445#issuecomment-2484334036 function resolveBareSpecifier(specifier) { let resolvedPath; try { resolvedPath = import.meta.resolve(specifier); } catch (e) { diagnostics.set( specifier, `ERROR (${e.code}): unable to resolve specifier => \`${specifier}\`\n${e.message}`, ); } return resolvedPath; } /* * Find root directory for a package based on result of import.meta.resolve, since dependencyName could show in multiple places * until this becomes a thing - https://github.com/nodejs/node/issues/49445 * { * dependencyName: 'lit-html', * resolved: 'file:///path/to/project/greenwood-lit-ssr/node_modules/.pnpm/lit-html@3.2.1/node_modules/lit-html/node/lit-html.js', * root: 'file:///path/to/project/greenwood-lit-ssr/node_modules/.pnpm/lit-html@3.2.1/node_modules/lit-html/ * } */ function derivePackageRoot(resolved) { // can't rely on the specifier, for example in monorepos // where @foo/bar may point to a non node_modules location // e.g. packages/some-namespace/package.json // so we walk backwards looking for nearest package.json const segments = resolved .replace("file://", "") .split("/") .filter((segment) => segment !== "") .reverse(); let root = resolved.replace(segments[0], ""); for (const segment of segments.slice(1)) { if (fs.existsSync(new URL("./package.json", root))) { // we have to check that this package.json actually has as a name AND version // https://github.com/moment/luxon/issues/1543#issuecomment-2546858540 // https://github.com/ProjectEvergreen/greenwood/issues/1349 const resolvedPackageJson = JSON.parse( fs.readFileSync(new URL("./package.json", root), "utf-8"), ); const { name, version } = resolvedPackageJson; if (name && version) { break; } } // make sure we are trimming from the end // https://github.com/ProjectEvergreen/greenwood/issues/1386 root = root.substring(0, root.lastIndexOf(segment)); } return root !== "" ? root : null; } // helper function to convert export patterns to a regex (thanks ChatGPT :D) function globToRegex(pattern) { // Escape special regex characters pattern = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&"); // Replace glob `*` with regex `[^/]*` (any characters except slashes) pattern = pattern.replace(/\*/g, "[^/]*"); // Replace glob `**` with regex `(.*)` (zero or more directories or files) // pattern = pattern.replace(/\*\*/g, '(.*)'); // Return the final regex return new RegExp("^" + pattern + "$"); } // helper function to convert path to its lowest common root // e.g. ./img/path/*/index.js -> /img/path // https://unpkg.com/browse/@uswds/uswds@3.10.0/package.json function patternRoot(pattern) { const segments = pattern.split("/").filter((segment) => segment !== "."); let root = ""; for (const segment of segments) { // is there a better way to fuzzy test for a filename other than checking for a dot? if (segment.indexOf("*") < 0 && segment.indexOf(".") < 0) { root += `/${segment}`; } else { break; } } return root; } /* * https://nodejs.org/api/packages.html#subpath-patterns * * Examples * "./icons/*": "./icons/*" - https://unpkg.com/browse/@spectrum-web-components/icons-workflow@1.0.1/package.json * "./components/*": "./dist/components/*.js" - https://unpkg.com/browse/@uswds/web-components@0.0.1-alpha/package.json * "./src/components/*": "./src/components/* /index.js - https://unpkg.com/browse/@uswds/web-components@0.0.1-alpha/package.json * "./*": { "default": "./dist/*.ts.js" } - https://unpkg.com/browse/signal-utils@0.21.1/package.json */ async function walkExportPatterns(dependency, sub, subValue, resolvedRoot) { // find the "deepest" segment we can start from to avoid unnecessary file scanning / crawling const rootSubOffset = patternRoot(sub); const rootSubValueOffset = patternRoot(subValue); // ideally we can use fs.glob when it comes out of experimental // https://nodejs.org/docs/latest-v22.x/api/fs.html#fspromisesglobpattern-options function walkDirectoryForExportPatterns(directoryUrl) { const filesInDir = fs.readdirSync(directoryUrl); filesInDir.forEach((file) => { const filePathUrl = new URL(`./${file}`, directoryUrl); const stat = fs.statSync(filePathUrl); const pattern = `${resolvedRoot}${subValue.replace("./", "")}`; const regexPattern = globToRegex(pattern); if (stat.isDirectory()) { walkDirectoryForExportPatterns(new URL(`./${file}/`, directoryUrl)); } else if (regexPattern.test(filePathUrl.href)) { const relativePath = filePathUrl.href.replace(resolvedRoot, ""); // naive way to offset a subValue pattern to the sub pattern when dealing with wildcards // ex. "./js/*": "./packages/*/src/index.js" -> /js/<package-name>/src/index.js const rootSubRelativePath = sub.endsWith("*") ? `./${relativePath}` .replace(subValue.split("*")[0], "") .replace(subValue.split("*")[1], "") : relativePath.replace(rootSubValueOffset, ""); updateImportMap( `${dependency}${rootSubOffset}/${rootSubRelativePath}`, relativePath, resolvedRoot, ); } }); } walkDirectoryForExportPatterns(new URL(`.${rootSubValueOffset}/`, resolvedRoot)); } function trackExportConditions(dependency, exports, sub, condition, resolvedRoot) { if (typeof exports[sub] === "object") { // also check for nested conditions of conditions, default to default for now // https://unpkg.com/browse/@floating-ui/dom@1.6.12/package.json if (sub === ".") { updateImportMap( dependency, `${exports[sub][condition].default ?? exports[sub][condition]}`, resolvedRoot, ); } else { updateImportMap( `${dependency}/${sub}`, `${exports[sub][condition].default ?? exports[sub][condition]}`, resolvedRoot, ); } } else { // https://unpkg.com/browse/redux@5.0.1/package.json updateImportMap(dependency, `${exports[sub][condition]}`); } } // https://nodejs.org/api/packages.html#conditional-exports async function walkPackageForExports(dependency, packageJson, resolvedRoot) { const { exports, module, main } = packageJson; // favor exports over main / module // favor exports over main / module if (typeof exports === "string") { // https://unpkg.com/browse/robust-predicates@3.0.2/package.json updateImportMap(dependency, exports, resolvedRoot); } else if (typeof exports === "object") { for (const sub in exports) { /* * test for conditional subpath exports * 1. import * 2. module-sync * 3. default */ if (typeof exports[sub] === "object") { let matched = false; for (const condition of SUPPORTED_EXPORT_CONDITIONS) { if (exports[sub][condition]) { matched = true; if (sub.indexOf("*") >= 0) { await walkExportPatterns(dependency, sub, exports[sub][condition], resolvedRoot); } else { trackExportConditions(dependency, exports, sub, condition, resolvedRoot); } break; } } if (!matched) { // ex. https://unpkg.com/browse/matches-selector@1.2.0/package.json diagnostics.set( dependency, `no supported export conditions (\`${SUPPORTED_EXPORT_CONDITIONS.join(", ")}\`) for dependency => \`${dependency}\``, ); } } else { // handle (unconditional) subpath exports if (sub === ".") { updateImportMap(dependency, `${exports[sub]}`, resolvedRoot); } else if (sub.indexOf("*") >= 0) { await walkExportPatterns(dependency, sub, exports[sub], resolvedRoot); } else if (SUPPORTED_EXPORT_CONDITIONS.includes(sub)) { // filter out for just supported top level conditions // https://unpkg.com/browse/d3@7.9.0/package.json updateImportMap(dependency, `${exports[sub]}`, resolvedRoot); } else { // let all other conditions "pass through" as is updateImportMap(`${dependency}/${sub}`, `${exports[sub]}`, resolvedRoot); } } } } else if (module || main) { updateImportMap(dependency, `${module ?? main}`, resolvedRoot); } else if (fs.existsSync(new URL("./index.js", resolvedRoot))) { // if an index.js file exists but with no main entry point, then it should count as a main entry point // https://docs.npmjs.com/cli/v7/configuring-npm/package-json#main // https://unpkg.com/browse/object-assign@4.1.1/package.json updateImportMap(dependency, "index.js", resolvedRoot); } else { // ex: https://unpkg.com/browse/uuid@3.4.0/package.json diagnostics.set( dependency, `WARNING: No supported entry point detected for => \`${dependency}\``, ); } } // we recursively cache / memoize walkedPackages to account for scenarios where Greenwood can (pre)render concurrently async function walkPackageJson(packageJson = {}, walkedPackages = new Set()) { try { const dependencies = Object.keys(packageJson.dependencies || {}); for (const dependency of dependencies) { const resolved = resolveBareSpecifier(dependency); if (resolved) { const resolvedRoot = derivePackageRoot(resolved); if (resolvedRoot) { const resolvedPackageJson = // @ts-expect-error see https://github.com/microsoft/TypeScript/issues/42866 (await import(new URL("./package.json", resolvedRoot), { with: { type: "json" } })) .default; const { name } = resolvedPackageJson; await walkPackageForExports(dependency, resolvedPackageJson, resolvedRoot); if (!walkedPackages.has(name)) { walkedPackages.add(name); await walkPackageJson(resolvedPackageJson, walkedPackages); } } else { // ignore built-ins since NodeJS resolves them automatically // https://github.com/nodejs/node/issues/56652 // https://nodejs.org/api/modules.html#built-in-modules if (!isBuiltin(resolved)) { diagnostics.set( dependency, `WARNING: No package.json resolved for => \`${dependency}\`, resolved to \`${resolved}\``, ); } } } } } catch (e) { console.error("Error building up import map", e); } return { importMap, diagnostics }; } export { walkPackageJson, resolveBareSpecifier, derivePackageRoot, IMPORT_MAP_RESOLVED_PREFIX };