UNPKG

typedoc

Version:

Create api documentation for TypeScript projects.

428 lines (427 loc) 14.6 kB
import * as fs from "fs"; import { promises as fsp } from "fs"; import { Minimatch } from "minimatch"; import { dirname, join, relative, resolve } from "path"; import { optional, validate } from "./validation.js"; import { createMinimatch, normalizePath } from "./paths.js"; import { filterMap } from "./array.js"; import { escapeRegExp } from "./general.js"; import { ok } from "assert"; export function isFile(file) { try { return fs.statSync(file).isFile(); } catch { return false; } } export function isDir(path) { try { return fs.statSync(path).isDirectory(); } catch { return false; } } export function deriveRootDir(globPaths) { const normalized = globPaths.map(normalizePath); const globs = createMinimatch(normalized); const rootPaths = globs.flatMap((glob, i) => filterMap(glob.set, (set) => { const stop = set.findIndex((part) => typeof part !== "string"); if (stop === -1) { return normalized[i]; } else { const kept = set.slice(0, stop).join("/"); return normalized[i].substring(0, normalized[i].indexOf(kept) + kept.length); } })); return getCommonDirectory(rootPaths); } /** * Get the longest directory path common to all files. */ export function getCommonDirectory(files) { if (!files.length) { return ""; } const roots = files.map((f) => f.split(/\\|\//)); if (roots.length === 1) { return roots[0].slice(0, -1).join("/"); } let i = 0; while (i < roots[0].length && new Set(roots.map((part) => part[i])).size === 1) { i++; } return roots[0].slice(0, i).join("/"); } /** * Load the given file and return its contents. * * @param file The path of the file to read. * @returns The files contents. */ export function readFile(file) { const buffer = fs.readFileSync(file); switch (buffer[0]) { case 0xfe: if (buffer[1] === 0xff) { let i = 0; while (i + 1 < buffer.length) { const temp = buffer[i]; buffer[i] = buffer[i + 1]; buffer[i + 1] = temp; i += 2; } return buffer.toString("ucs2", 2); } break; case 0xff: if (buffer[1] === 0xfe) { return buffer.toString("ucs2", 2); } break; case 0xef: if (buffer[1] === 0xbb) { return buffer.toString("utf8", 3); } } return buffer.toString("utf8", 0); } /** * Write a file to disc. * * If the containing directory does not exist it will be created. * * @param fileName The name of the file that should be written. * @param data The contents of the file. */ export function writeFileSync(fileName, data) { fs.mkdirSync(dirname(normalizePath(fileName)), { recursive: true }); fs.writeFileSync(normalizePath(fileName), data); } /** * Write a file to disc. * * If the containing directory does not exist it will be created. * * @param fileName The name of the file that should be written. * @param data The contents of the file. */ export async function writeFile(fileName, data) { await fsp.mkdir(dirname(normalizePath(fileName)), { recursive: true, }); await fsp.writeFile(normalizePath(fileName), data); } /** * Copy a file or directory recursively. */ export async function copy(src, dest) { const stat = await fsp.stat(src); if (stat.isDirectory()) { const contained = await fsp.readdir(src); await Promise.all(contained.map((file) => copy(join(src, file), join(dest, file)))); } else if (stat.isFile()) { await fsp.mkdir(dirname(dest), { recursive: true }); await fsp.copyFile(src, dest); } else { // Do nothing for FIFO, special devices. } } export function copySync(src, dest) { const stat = fs.statSync(src); if (stat.isDirectory()) { const contained = fs.readdirSync(src); contained.forEach((file) => copySync(join(src, file), join(dest, file))); } else if (stat.isFile()) { fs.mkdirSync(dirname(dest), { recursive: true }); fs.copyFileSync(src, dest); } else { // Do nothing for FIFO, special devices. } } // cache of fs.realpathSync results to avoid extra I/O const realpathCache = new Map(); export function discoverFiles(rootDir, controller) { const result = []; const dirs = [normalizePath(rootDir).split("/")]; // cache of real paths to avoid infinite recursion const symlinkTargetsSeen = new Set(); const { matchDirectories = false, followSymlinks = false } = controller; let dir = dirs.shift(); const handleFile = (path) => { const childPath = [...dir, path].join("/"); if (controller.matches(childPath)) { result.push(childPath); } }; const handleDirectory = (path) => { const childPath = [...dir, path]; if (controller.shouldRecurse(childPath)) { dirs.push(childPath); } }; const handleSymlink = (path) => { const childPath = [...dir, path].join("/"); let realpath; try { realpath = realpathCache.get(childPath) ?? fs.realpathSync(childPath); realpathCache.set(childPath, realpath); } catch { return; } if (symlinkTargetsSeen.has(realpath)) { return; } symlinkTargetsSeen.add(realpath); try { const stats = fs.statSync(realpath); if (stats.isDirectory()) { handleDirectory(path); } else if (stats.isFile()) { handleFile(path); } else if (stats.isSymbolicLink()) { const dirpath = dir.join("/"); if (dirpath === realpath) { // special case: real path of symlink is the directory we're currently traversing return; } const targetPath = relative(dirpath, realpath); handleSymlink(targetPath); } // everything else should be ignored } catch (e) { // invalid symbolic link; ignore } }; while (dir) { if (matchDirectories && controller.matches(dir.join("/"))) { result.push(dir.join("/")); } for (const child of fs.readdirSync(dir.join("/"), { withFileTypes: true, })) { if (child.isFile()) { handleFile(child.name); } else if (child.isDirectory()) { handleDirectory(child.name); } else if (followSymlinks && child.isSymbolicLink()) { handleSymlink(child.name); } } dir = dirs.shift(); } return result; } /** * Simpler version of `glob.sync` that only covers our use cases, always ignoring node_modules. */ export function glob(pattern, root, options = {}) { const mini = new Minimatch(normalizePath(pattern)); const shouldIncludeNodeModules = pattern.includes("node_modules"); const controller = { matches(path) { return mini.match(path); }, shouldRecurse(childPath) { // if we _specifically asked_ for something in node_modules, fine, otherwise ignore it // to avoid globs like '**/*.ts' finding all the .d.ts files in node_modules. // however, if the pattern is something like `!**/node_modules/**`, this will also // cause node_modules to be considered, though it will be discarded by minimatch. if (childPath[childPath.length - 1] === "node_modules" && !shouldIncludeNodeModules) { return false; } return mini.set.some((row) => mini.matchOne(childPath, row, /* partial */ true)); }, matchDirectories: options.includeDirectories, followSymlinks: options.followSymlinks, }; return discoverFiles(root, controller); } export function hasTsExtension(path) { return /\.[cm]?ts$|\.tsx$/.test(path); } export function hasDeclarationFileExtension(path) { return /\.d\.[cm]?ts$/.test(path); } export function discoverInParentDir(name, dir, read) { if (!isDir(dir)) return; const reachedTopDirectory = (dirName) => dirName === resolve(join(dirName, "..")); while (!reachedTopDirectory(dir)) { for (const file of fs.readdirSync(dir)) { if (file.toLowerCase() !== name.toLowerCase()) continue; try { const content = read(readFile(join(dir, file))); if (content != null) { return { file: join(dir, file), content }; } } catch { // Ignore, file didn't pass validation } } dir = resolve(join(dir, "..")); } } export function discoverInParentDirExactMatch(name, dir, read) { if (!isDir(dir)) return; const reachedTopDirectory = (dirName) => dirName === resolve(join(dirName, "..")); while (!reachedTopDirectory(dir)) { try { const content = read(readFile(join(dir, name))); if (content != null) { return { file: join(dir, name), content }; } } catch { // Ignore, file didn't pass validation } dir = resolve(join(dir, "..")); } } export function discoverPackageJson(dir) { return discoverInParentDirExactMatch("package.json", dir, (content) => { const pkg = JSON.parse(content); if (validate({ name: String, version: optional(String) }, pkg)) { return pkg; } }); } // dir -> package name according to package.json in this or some parent dir const packageCache = new Map(); export function findPackageForPath(sourcePath) { // Attempt to decide package name from path if it contains "node_modules" let startIndex = sourcePath.lastIndexOf("node_modules/"); if (startIndex !== -1) { startIndex += "node_modules/".length; let stopIndex = sourcePath.indexOf("/", startIndex); // Scoped package, e.g. `@types/node` if (sourcePath[startIndex] === "@") { stopIndex = sourcePath.indexOf("/", stopIndex + 1); } return sourcePath.substring(startIndex, stopIndex); } const dir = dirname(sourcePath); const cache = packageCache.get(dir); if (cache) { return cache; } const packageJson = discoverPackageJson(dir); if (packageJson) { packageCache.set(dir, packageJson.content.name); return packageJson.content.name; } } export function inferPackageEntryPointPaths(packagePath) { const packageDir = dirname(packagePath); const packageJson = JSON.parse(readFile(packagePath)); const exports = packageJson.exports; if (typeof exports === "string") { return resolveExport(packageDir, ".", exports, false); } if (!exports || typeof exports !== "object") { if (typeof packageJson.main === "string") { return [[".", resolve(packageDir, packageJson.main)]]; } return []; } const results = []; if (Array.isArray(exports)) { results.push(...resolveExport(packageDir, ".", exports, true)); } else { for (const [importPath, exp] of Object.entries(exports)) { results.push(...resolveExport(packageDir, importPath, exp, false)); } } return results; } function resolveExport(packageDir, name, exportDeclaration, validatePath) { if (typeof exportDeclaration === "string") { return resolveStarredExport(packageDir, name, exportDeclaration, validatePath); } if (Array.isArray(exportDeclaration)) { for (const item of exportDeclaration) { const result = resolveExport(packageDir, name, item, true); if (result.length) { return result; } } return []; } const EXPORT_CONDITIONS = ["typedoc", "types", "import", "node", "default"]; for (const cond in exportDeclaration) { if (EXPORT_CONDITIONS.includes(cond)) { return resolveExport(packageDir, name, exportDeclaration[cond], false); } } // No recognized export condition return []; } function isWildcardName(name) { let starCount = 0; for (let i = 0; i < name.length; ++i) { if (name[i] === "*") { ++starCount; } } return starCount === 1; } function resolveStarredExport(packageDir, name, exportDeclaration, validatePath) { // Wildcards only do something if there is exactly one star in the name // If there isn't any star in the destination, all entries map to one file // so don't bother enumerating possible files. if (isWildcardName(name) && exportDeclaration.includes("*")) { // Construct a pattern which we can use to determine if a wildcard matches // This will look something like: /^/app\/package\/(.*).js$/ // The destination may have multiple wildcards, in which case they should // contain the same text, so we replace "*" with backreferences for all // but the first occurrence. let first = true; const matcher = new RegExp("^" + escapeRegExp(normalizePath(packageDir) + "/" + exportDeclaration.replace(/^\.\//, "")).replaceAll("\\*", () => { if (first) { first = false; return "(.*)"; } return "\\1"; }) + "$"); const matchedFiles = discoverFiles(packageDir, { matches(path) { return matcher.test(path); }, shouldRecurse(path) { return path[path.length - 1] !== "node_modules"; }, }); return matchedFiles.flatMap((path) => { const starContent = path.match(matcher); ok(starContent, "impossible, discoverFiles uses matcher"); return [[name.replace("*", starContent[1]), path]]; }); } const exportPath = resolve(packageDir, exportDeclaration); if (validatePath && !fs.existsSync(exportPath)) { return []; } return [[name, exportPath]]; }