UNPKG

storybook

Version:

Storybook: Develop, document, and test UI components in isolation

489 lines (480 loc) 22.1 kB
import CJS_COMPAT_NODE_URL_qjinduibsyq from 'node:url'; import CJS_COMPAT_NODE_PATH_qjinduibsyq from 'node:path'; import CJS_COMPAT_NODE_MODULE_qjinduibsyq from "node:module"; var __filename = CJS_COMPAT_NODE_URL_qjinduibsyq.fileURLToPath(import.meta.url); var __dirname = CJS_COMPAT_NODE_PATH_qjinduibsyq.dirname(__filename); var require = CJS_COMPAT_NODE_MODULE_qjinduibsyq.createRequire(import.meta.url); // ------------------------------------------------------------ // end of CJS compatibility banner, injected by Storybook's esbuild configuration // ------------------------------------------------------------ import { require_dist } from "../_node-chunks/chunk-FE5AV6EP.js"; import { isInNodeModules, slash } from "../_node-chunks/chunk-J4Q2MOKA.js"; import { __toESM } from "../_node-chunks/chunk-4US4PNS3.js"; // src/component-meta/ComponentMetaManager.ts var import_ts_dedent = __toESM(require_dist(), 1); import { logger, once } from "storybook/internal/node-logger"; import { existsSync, watch } from "fs"; import * as path from "path"; import * as v8 from "v8"; var rootTsConfigNames = ["tsconfig.json", "jsconfig.json"], RECYCLE_HEAP_PRESSURE_RATIO = 0.7, ComponentMetaManager = class { /** * @param recycleHeapPressureRatio Fraction of the V8 heap limit at which the shared program(s) are * recycled (see {@link RECYCLE_HEAP_PRESSURE_RATIO}). Exposed for tuning and for the memory * regression gate, which passes `Infinity` to disable recycling and assert the OOM still happens * without the fix. Defaults to {@link RECYCLE_HEAP_PRESSURE_RATIO}. */ constructor(typescript, factory, recycleHeapPressureRatio = RECYCLE_HEAP_PRESSURE_RATIO) { this.typescript = typescript; this.factory = factory; // Adapted from: // https://github.com/volarjs/volar.js/blob/882cd56d46a13d272f34e451f495d3d62251969a/packages/language-server/lib/project/typescriptProject.ts#L34-L37 this.configProjects = /* @__PURE__ */ new Map(); this.rootTsConfigs = /* @__PURE__ */ new Set(); this.searchedDirs = /* @__PURE__ */ new Set(); // Our own file watching layer this.watching = !1; this.watchersByDir = /* @__PURE__ */ new Map(); this.pendingEvents = /* @__PURE__ */ new Map(); this.heapRecycleThresholdBytes = Math.floor( v8.getHeapStatistics().heap_size_limit * recycleHeapPressureRatio ); } // --------------------------------------------------------------------------- // Adapted from: // https://github.com/volarjs/volar.js/blob/882cd56d46a13d272f34e451f495d3d62251969a/packages/language-server/lib/project/typescriptProject.ts#L70-L79 // --------------------------------------------------------------------------- getProjectForFile(fileName) { let tsconfig = this.findMatchTSConfig(fileName); return tsconfig ? this.getOrCreateConfiguredProject(tsconfig) ?? this.getOrCreateInferredProject(fileName) : this.getOrCreateInferredProject(fileName); } /** * Reclaim the shared program(s)' resident type-resolution cache when heap usage approaches the V8 * limit, preventing the next extraction's spike from OOMing the dev server. Callers with a * batch-extraction surface invoke this after each batch. */ recycleProjectsIfHeapPressured() { if (this.configProjects.size === 0 && !this.inferredProject || process.memoryUsage().heapUsed < this.heapRecycleThresholdBytes) return; let heapLimitMb = Math.round(v8.getHeapStatistics().heap_size_limit / (1024 * 1024)); once.warn(import_ts_dedent.dedent` Storybook's experimental docgen server is nearing the Node.js memory limit (~${heapLimitMb} MB) while extracting component types, and recycled its TypeScript program to avoid an out-of-memory crash. This can briefly slow down the docs and Controls panels. If this happens often, raise Node's memory limit before starting Storybook, for example: NODE_OPTIONS="--max-old-space-size=${heapLimitMb * 2}" `); for (let project of this.configProjects.values()) project.dispose(); this.inferredProject?.dispose(), this.configProjects.clear(), this.inferredProject = void 0, this.factory.recycle?.(); } // --------------------------------------------------------------------------- // Adapted from: // https://github.com/volarjs/volar.js/blob/882cd56d46a13d272f34e451f495d3d62251969a/packages/language-server/lib/project/typescriptProject.ts#L101-L234 // --------------------------------------------------------------------------- findMatchTSConfig(filePath) { let fileName = slash(filePath), dir = path.dirname(fileName); for (; !this.searchedDirs.has(dir); ) { this.searchedDirs.add(dir); for (let tsConfigName of rootTsConfigNames) { let tsconfigPath = slash(path.join(dir, tsConfigName)); this.typescript.sys.fileExists(tsconfigPath) && this.rootTsConfigs.add(tsconfigPath); } let parent = path.dirname(dir); if (parent === dir) break; dir = parent; } if (this.rootTsConfigs.size === 0) return null; let prepareClosestRootCommandLine = () => { let matches = []; for (let rootTsConfig of this.rootTsConfigs) isFileInDir(fileName, path.dirname(rootTsConfig)) && matches.push(rootTsConfig); matches = matches.sort((a, b) => sortTSConfigs(fileName, a, b)), matches.length && getCommandLine(matches[0]); }, findIndirectReferenceTsconfig = () => findTSConfig((tsconfig) => !!this.configProjects.get(tsconfig)?.hasSourceFile(fileName)), findDirectIncludeTsconfig = () => findTSConfig((tsconfig) => { let commandLine = getCommandLine(tsconfig); return new Set(commandLine?.fileNames ?? []).has(fileName); }), findTSConfig = (match) => { let checked = /* @__PURE__ */ new Set(); for (let rootTsConfig of [...this.rootTsConfigs].sort( (a, b) => sortTSConfigs(fileName, a, b) )) { let project = this.configProjects.get(rootTsConfig); if (project) { let chains = getReferencesChains(project.getCommandLine(), rootTsConfig, []); chains = chains.reverse(); for (let chain of chains) for (let i = chain.length - 1; i >= 0; i--) { let tsconfig = chain[i]; if (!checked.has(tsconfig) && (checked.add(tsconfig), match(tsconfig))) return tsconfig; } } } return null; }, getReferencesChains = (commandLine, tsConfig, before) => { if (commandLine.projectReferences?.length) { let newChains = []; for (let projectReference of commandLine.projectReferences) { let tsConfigPath = slash(projectReference.path); if (this.typescript.sys.directoryExists(tsConfigPath)) { let newTsConfigPath = path.join(tsConfigPath, "tsconfig.json"), newJsConfigPath = path.join(tsConfigPath, "jsconfig.json"); this.typescript.sys.fileExists(newTsConfigPath) ? tsConfigPath = newTsConfigPath : this.typescript.sys.fileExists(newJsConfigPath) && (tsConfigPath = newJsConfigPath); } let beforeIndex = before.indexOf(tsConfigPath); if (beforeIndex >= 0) newChains.push(before.slice(0, Math.max(beforeIndex, 1))); else { let referenceCommandLine = getCommandLine(tsConfigPath); if (referenceCommandLine) for (let chain of getReferencesChains(referenceCommandLine, tsConfigPath, [ ...before, tsConfig ])) newChains.push(chain); } } return newChains; } else return [[...before, tsConfig]]; }, getCommandLine = (tsConfig) => this.getOrCreateConfiguredProject(tsConfig)?.getCommandLine(); return prepareClosestRootCommandLine(), findDirectIncludeTsconfig() ?? findIndirectReferenceTsconfig(); } // --------------------------------------------------------------------------- // Adapted from: // https://github.com/volarjs/volar.js/blob/882cd56d46a13d272f34e451f495d3d62251969a/packages/language-server/lib/project/typescriptProject.ts#L236-L256 // --------------------------------------------------------------------------- getOrCreateConfiguredProject(tsconfig) { tsconfig = slash(tsconfig); let project = this.configProjects.get(tsconfig); if (!project) try { let getCommandLine = () => this.factory.parseCommandLine(tsconfig); project = this.factory.createConfiguredProject(getCommandLine(), tsconfig, getCommandLine), this.configProjects.set(tsconfig, project), this.watching && (this.watchDirectory(path.dirname(tsconfig)), this.watchProgramSourceDirs(project)); } catch (err) { return logger.debug(`[component-meta] Failed to parse tsconfig ${tsconfig}: ${err}`), null; } return project; } // --------------------------------------------------------------------------- // Adapted from: // https://github.com/volarjs/volar.js/blob/882cd56d46a13d272f34e451f495d3d62251969a/packages/language-server/lib/project/typescriptProject.ts#L258-L284 // --------------------------------------------------------------------------- getOrCreateInferredProject(fileName) { return this.inferredProject || (this.inferredProject = this.factory.createInferredProject()), this.inferredProject.ensureFiles([fileName]), this.inferredProject; } // --------------------------------------------------------------------------- // File events // --------------------------------------------------------------------------- /** * Broadcast file changes to all projects. Each project selectively bumps projectVersion. * * Adapted from: * https://github.com/volarjs/volar.js/blob/882cd56d46a13d272f34e451f495d3d62251969a/packages/kit/lib/createChecker.ts#L409-L432 */ onFilesChanged(changes) { for (let project of this.configProjects.values()) project.onFilesChanged(changes); this.inferredProject?.onFilesChanged(changes); } /** * Adapted from: * https://github.com/volarjs/volar.js/blob/882cd56d46a13d272f34e451f495d3d62251969a/packages/language-server/lib/project/typescriptProject.ts#L43-L68 */ onConfigChanged(configPath, type) { if (configPath = slash(configPath), type === "created") this.rootTsConfigs.add(configPath); else if ((type === "changed" || type === "deleted") && this.configProjects.has(configPath)) { type === "deleted" && this.rootTsConfigs.delete(configPath); let project = this.configProjects.get(configPath); this.configProjects.delete(configPath), project?.dispose(); } this.searchedDirs.clear(); } // --------------------------------------------------------------------------- // Our own file watching layer (no Volar equivalent — we're standalone) // --------------------------------------------------------------------------- startWatching() { if (!this.watching) { this.watching = !0; for (let tsconfig of this.configProjects.keys()) this.watchDirectory(path.dirname(tsconfig)); this.watchProgramSourceDirs(); } } /** * Watch directories that contain source files from all TS programs. This covers monorepo setups * where stories import components via path aliases (e.g. apps/storybook/ imports from * packages/ui/ via tsconfig paths). */ watchProgramSourceDirs(singleProject) { let dirs = /* @__PURE__ */ new Set(); if (singleProject) for (let filePath of singleProject.getSourceFilePaths()) dirs.add(path.dirname(filePath)); else { for (let project of this.configProjects.values()) for (let filePath of project.getSourceFilePaths()) dirs.add(path.dirname(filePath)); if (this.inferredProject) for (let filePath of this.inferredProject.getSourceFilePaths()) dirs.add(path.dirname(filePath)); } let roots = /* @__PURE__ */ new Set(); for (let dir of dirs) { let candidate = dir; for (; candidate !== path.dirname(candidate); ) { let normalized = slash(candidate), alreadyWatched = !1; for (let watched of this.watchersByDir.keys()) if (normalized === watched || normalized.startsWith(watched + "/")) { alreadyWatched = !0; break; } if (alreadyWatched) break; if (this.typescript.sys.fileExists(path.join(candidate, "package.json")) || this.typescript.sys.fileExists(path.join(candidate, "tsconfig.json"))) { roots.add(candidate); break; } candidate = path.dirname(candidate); } } for (let root of roots) this.watchDirectory(root); } watchDirectory(dir) { if (!this.watching) return; let normalized = slash(dir); for (let watched of this.watchersByDir.keys()) if (normalized === watched || normalized.startsWith(watched + "/")) return; for (let [watched, watcher] of this.watchersByDir) watched.startsWith(normalized + "/") && (watcher.close(), this.watchersByDir.delete(watched)); try { let watcher = watch(dir, { recursive: !0 }, (eventType, filename) => { if (!filename) return; let filePath = slash(path.resolve(dir, filename)); if (filePath.includes("/node_modules/") || filePath.includes("/.git/")) return; let existing = this.pendingEvents.get(filePath); existing && clearTimeout(existing), this.pendingEvents.set( filePath, setTimeout(() => { this.pendingEvents.delete(filePath), eventType === "rename" ? existsSync(filePath) ? this.handleFileEvent(filePath, "created") : this.handleFileEvent(filePath, "deleted") : this.handleFileEvent(filePath, "changed"); }, 50) ); }); watcher.unref(), this.watchersByDir.set(normalized, watcher); } catch (err) { logger.debug(`[component-meta] Failed to watch directory ${normalized}: ${err}`); } } stopWatching() { for (let timeout of this.pendingEvents.values()) clearTimeout(timeout); this.pendingEvents.clear(); for (let watcher of this.watchersByDir.values()) watcher.close(); this.watchersByDir.clear(), this.watching = !1; } /** * Map raw fs.watch events to LSP-style FileChangeType before broadcasting. * * Fs.watch reports atomic saves (sed -i, editors) as `rename` → we classify as `created` (file * exists after rename). But an IDE/LSP would report an atomic save of an _existing_ file as * `Changed`, not `Created`. * * We reclassify here so that onFilesChanged stays 1:1 with Volar Kit. */ handleFileEvent(filePath, type) { if (type === "created") { for (let project of this.configProjects.values()) if (project.hasSourceFile(filePath)) { type = "changed"; break; } type === "created" && this.inferredProject?.hasSourceFile(filePath) && (type = "changed"); } let basename2 = path.basename(filePath); if (rootTsConfigNames.includes(basename2)) { this.onConfigChanged(filePath, type); return; } this.onFilesChanged([{ filePath, type }]); } dispose() { this.stopWatching(); for (let project of this.configProjects.values()) project.dispose(); this.inferredProject?.dispose(), this.configProjects.clear(), this.inferredProject = void 0, this.factory.dispose?.(), this.searchedDirs.clear(), this.rootTsConfigs.clear(); } }; function sortTSConfigs(file, a, b) { let inA = isFileInDir(file, path.dirname(a)), inB = isFileInDir(file, path.dirname(b)); if (inA !== inB) return (inB ? 1 : 0) - (inA ? 1 : 0); let aLength = a.split("/").length, bLength = b.split("/").length; if (aLength === bLength) { let aWeight = path.basename(a) === "tsconfig.json" ? 1 : 0; return (path.basename(b) === "tsconfig.json" ? 1 : 0) - aWeight; } return bLength - aLength; } function isFileInDir(fileName, dir) { let relative2 = path.relative(dir, fileName); return !!relative2 && !relative2.startsWith("..") && !path.isAbsolute(relative2); } // src/component-meta/jsdoc-info.ts function resolveExportedSymbol(typescript, checker, sourceFile, exportName) { let moduleSymbol = checker.getSymbolAtLocation(sourceFile), symbol = moduleSymbol ? checker.getExportsOfModule(moduleSymbol).find((candidate) => candidate.getName() === exportName) : void 0; return symbol && symbol.flags & typescript.SymbolFlags.Alias ? checker.getAliasedSymbol(symbol) : symbol; } function extractComponentJsDocInfo(typescript, checker, symbol) { let description = typescript.displayPartsToString(symbol.getDocumentationComment(checker)), jsDocTags = {}; for (let tag of symbol.getJsDocTags(checker)) (jsDocTags[tag.name] ??= []).push(typescript.displayPartsToString(tag.text ?? []).trim()); return { description, jsDocTags }; } // src/component-meta/parse-tsconfig.ts import { dirname as dirname2 } from "node:path"; function parseTsconfigCommandLine(typescript, tsconfig, extraFileExtensions) { let config = typescript.readJsonConfigFile(tsconfig, typescript.sys.readFile), content = typescript.parseJsonSourceFileConfigFileContent( config, typescript.sys, dirname2(tsconfig), {}, tsconfig, void 0, extraFileExtensions ); return content.options.outDir = void 0, content.fileNames = content.fileNames.map(slash), content; } // src/component-meta/ProjectFileTracker.ts function filterSourceFilePaths(fileNames) { return fileNames.map(slash).filter((fileName) => !isInNodeModules(fileName)); } var ProjectFileTracker = class { constructor(fs, commandLine, snapshots, createSnapshot, getCommandLineFn) { this.fs = fs; this.commandLine = commandLine; this.snapshots = snapshots; this.createSnapshot = createSnapshot; this.getCommandLineFn = getCommandLineFn; this.projectVersion = 0; this.shouldCheckRootFiles = !1; this.fileVersions = /* @__PURE__ */ new Map(); } getProjectVersion() { return this.checkRootFilesUpdate(), this.projectVersion.toString(); } getScriptFileNames() { return this.checkRootFilesUpdate(), this.commandLine.fileNames; } getScriptVersion(fileName) { let normalized = slash(fileName), edits = this.fileVersions.get(normalized) ?? 0, cached = this.snapshots.get(normalized); return cached ? `${edits}:${cached[0] ?? 0}` : `${edits}:${this.fs.sys.getModifiedTime?.(normalized)?.valueOf() ?? 0}`; } /** Mtime-checked read-through: re-reads the file only when its mtime moved or was evicted. */ getSnapshot(fileName) { let normalized = slash(fileName), modifiedTime = this.fs.sys.getModifiedTime?.(normalized)?.valueOf(), cache = this.snapshots.get(normalized); if (!cache || cache[0] !== modifiedTime) { let text = this.fs.sys.fileExists(normalized) ? this.fs.sys.readFile(normalized) : void 0; this.snapshots.set(normalized, [ modifiedTime, text !== void 0 ? this.createSnapshot(text) : void 0 ]); } return this.snapshots.get(normalized)?.[1]; } /** * Batch-add files to the project's root set (inferred projects and on-demand inclusion). Bumps * projectVersion once for the whole batch to avoid repeated program rebuilds. */ ensureFiles(fileNames) { let added = !1; for (let fileName of fileNames) { let normalized = slash(fileName); this.commandLine.fileNames.includes(normalized) || (this.commandLine.fileNames.push(normalized), added = !0); } added && this.projectVersion++; } onFilesChanged(changes, isTracked) { for (let { filePath } of changes) { let fileName = slash(filePath); this.snapshots.delete(fileName), this.bumpFileVersion(fileName); } let oldVersion = this.projectVersion; for (let { filePath, type } of changes) { let fileName = slash(filePath); type === "changed" ? isTracked(fileName) && this.projectVersion++ : (this.projectVersion++, this.shouldCheckRootFiles = !0); } return this.projectVersion !== oldVersion; } ensureFresh(fileNames) { let stale = !1; for (let fileName of fileNames) { let normalized = slash(fileName), cache = this.snapshots.get(normalized); if (!cache) continue; let currentMtime = this.fs.sys.getModifiedTime?.(normalized)?.valueOf(); cache[0] !== currentMtime && (this.snapshots.delete(normalized), this.bumpFileVersion(normalized), stale = !0); } return stale && this.projectVersion++, stale; } bumpFileVersion(fileName) { this.fileVersions.set(fileName, (this.fileVersions.get(fileName) ?? 0) + 1); } checkRootFilesUpdate() { if (!this.shouldCheckRootFiles || (this.shouldCheckRootFiles = !1, !this.getCommandLineFn)) return; let newFileNames = this.getCommandLineFn().fileNames.map(slash); arrayItemsEqual(newFileNames, this.commandLine.fileNames) || (this.commandLine.fileNames = newFileNames, this.projectVersion++); } }; function arrayItemsEqual(a, b) { if (a.length !== b.length) return !1; let set = new Set(a); for (let file of b) if (!set.has(file)) return !1; return !0; } // src/component-meta/ProgramBackedProject.ts var ProgramBackedProject = class { dispose() { this.service.dispose(); } ensureFiles(fileNames) { this.files.ensureFiles(fileNames); } hasSourceFile(fileName) { return !!this.service.getProgram()?.getSourceFile(slash(fileName)); } getSourceFilePaths() { let program = this.service.getProgram(); return program ? filterSourceFilePaths(program.getSourceFiles().map((sourceFile) => sourceFile.fileName)) : []; } onFilesChanged(changes) { let program = this.service.getProgram(); this.files.onFilesChanged(changes, (fileName) => !!program?.getSourceFile(fileName)); } }; export { ComponentMetaManager, ProgramBackedProject, ProjectFileTracker, extractComponentJsDocInfo, filterSourceFilePaths, isFileInDir, parseTsconfigCommandLine, resolveExportedSymbol, sortTSConfigs };