storybook
Version:
Storybook: Develop, document, and test UI components in isolation
489 lines (480 loc) • 22.1 kB
JavaScript
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
};