Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions code/core/src/common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export * from './utils/remove';
export * from './utils/resolve-path-in-sb-cache';
export * from './utils/symlinks';
export * from './utils/template';
export * from './utils/tsconfig';
export * from './utils/validate-config';
export * from './utils/validate-configuration-files';
export * from './utils/satisfies';
Expand Down
105 changes: 105 additions & 0 deletions code/core/src/common/utils/__tests__/tsconfig.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';

import { afterEach, describe, expect, it, vi } from 'vitest';

import { findTsconfigPathForFile } from '../tsconfig';
import * as paths from '../paths';

const tempDirs: string[] = [];

afterEach(() => {
vi.restoreAllMocks();

for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});

describe('findTsconfigPathForFile', () => {
it('uses the referenced app tsconfig for Vite-style project references', () => {
const dir = createTempProject({
'tsconfig.json': JSON.stringify({
files: [],
references: [{ path: './tsconfig.app.json' }, { path: './tsconfig.node.json' }],
}),
'tsconfig.app.json': JSON.stringify({
compilerOptions: {
baseUrl: '.',
paths: {
'@ui/*': ['src/*'],
},
},
include: ['src'],
}),
'tsconfig.node.json': JSON.stringify({
include: ['vite.config.ts'],
}),
'src/Button.tsx': 'export const Button = () => null;',
});

vi.spyOn(paths, 'getProjectRoot').mockReturnValue(dir);

expect(findTsconfigPathForFile(dir, join(dir, 'src/Button.tsx'))).toBe(
join(dir, 'tsconfig.app.json')
);
});

it('keeps reference order for same-directory sibling tsconfigs that both match the file', () => {
const dir = createTempProject({
'tsconfig.json': JSON.stringify({
files: [],
references: [{ path: './tsconfig.app.json' }, { path: './tsconfig.node.json' }],
}),
'tsconfig.app.json': JSON.stringify({
compilerOptions: {
baseUrl: '.',
},
include: ['src'],
}),
'tsconfig.node.json': JSON.stringify({
compilerOptions: {
module: 'ESNext',
},
}),
'src/Button.tsx': 'export const Button = () => null;',
});

vi.spyOn(paths, 'getProjectRoot').mockReturnValue(dir);

expect(findTsconfigPathForFile(dir, join(dir, 'src/Button.tsx'))).toBe(
join(dir, 'tsconfig.app.json')
);
});

it('falls back to the nearest discovered tsconfig when no reference matches the file', () => {
const dir = createTempProject({
'tsconfig.json': JSON.stringify({
compilerOptions: {
baseUrl: '.',
},
}),
'src/Button.tsx': 'export const Button = () => null;',
});

vi.spyOn(paths, 'getProjectRoot').mockReturnValue(dir);

expect(findTsconfigPathForFile(dir, join(dir, 'src/Button.tsx'))).toBe(
join(dir, 'tsconfig.json')
);
});
});

function createTempProject(files: Record<string, string>) {
const dir = mkdtempSync(join(tmpdir(), 'storybook-tsconfig-'));
tempDirs.push(dir);

for (const [relativePath, content] of Object.entries(files)) {
const fullPath = join(dir, relativePath);
mkdirSync(dirname(fullPath), { recursive: true });
writeFileSync(fullPath, content, 'utf-8');
}

return dir;
}
200 changes: 200 additions & 0 deletions code/core/src/common/utils/tsconfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import { readFileSync } from 'node:fs';
import { basename, dirname, relative, resolve } from 'node:path';

import * as find from 'empathic/find';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can directly import { up } as it's treeshakable

import picomatch from 'picomatch';
import stripJsonComments from 'strip-json-comments';

import { getProjectRoot } from './paths';

const TSCONFIG_CANDIDATES = ['tsconfig.json', 'tsconfig.base.json', 'tsconfig.app.json'] as const;
const DEFAULT_EXCLUDE_PATTERNS = ['node_modules', 'bower_components', 'jspm_packages'];

type TsconfigReference = { path?: unknown };
type TsconfigConfig = {
exclude?: unknown;
files?: unknown;
include?: unknown;
references?: unknown;
};

type TsconfigEntry = {
config: TsconfigConfig;
path: string;
};

export const findTsconfigPath = (cwd: string): string | undefined => {
const projectRoot = getProjectRoot();

for (const candidate of TSCONFIG_CANDIDATES) {
const found = find.up(candidate, { cwd, last: projectRoot });
if (found) {
return found;
}
}
Comment thread
kasperpeulen marked this conversation as resolved.

return undefined;
};

/**
* Pick the tsconfig that actually applies to a given file, following project references when the
* nearest root config is just a references shell (for example Vite's `files: []` root tsconfig).
*
* This intentionally follows the same idea as the Volar-inspired selection logic used by
* `ComponentMetaManager`: prefer the config that really owns the file instead of stopping at the
* first config filename we discover. It extends the simpler fallback-chain fix from #34353 so
* docgen-style callers can handle project references too.
*/
export const findTsconfigPathForFile = (cwd: string, filePath: string): string | undefined => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a bit confused, there's also a findTsconfigPathForFile at reactDocgenTypescript.ts. Implementations seems different

const rootTsconfigPath = findTsconfigPath(cwd);
if (!rootTsconfigPath) {
return undefined;
}

const absoluteFilePath = resolve(filePath);
const matchingConfigs = collectTsconfigEntries(rootTsconfigPath, new Set()).filter((entry) =>
tsconfigIncludesFile(entry, absoluteFilePath)
);

matchingConfigs.sort((left, right) => compareTsconfigEntries(absoluteFilePath, left, right));

return matchingConfigs[0]?.path ?? rootTsconfigPath;
};

function collectTsconfigEntries(configPath: string, seen: Set<string>): TsconfigEntry[] {
const normalizedConfigPath = resolve(configPath);
if (seen.has(normalizedConfigPath)) {
return [];
}
seen.add(normalizedConfigPath);

const config = readTsconfigConfig(normalizedConfigPath);
if (!config) {
return [];
}

return [
{ path: normalizedConfigPath, config },
...getReferencedTsconfigPaths(normalizedConfigPath, config).flatMap((referencePath) =>
collectTsconfigEntries(referencePath, seen)
),
];
}

function getReferencedTsconfigPaths(configPath: string, config: TsconfigConfig) {
const references = Array.isArray(config.references)
? (config.references as TsconfigReference[])
: [];

return references
.map((reference) => reference.path)
.filter((referencePath): referencePath is string => typeof referencePath === 'string')
.map((referencePath) => resolve(dirname(configPath), referencePath))
.flatMap((referencePath) => resolveReferenceConfigPaths(referencePath));
}

function resolveReferenceConfigPaths(referencePath: string) {
if (referencePath.endsWith('.json')) {
return [referencePath];
}

return TSCONFIG_CANDIDATES.map((candidate) => resolve(referencePath, candidate));
}

function tsconfigIncludesFile(entry: TsconfigEntry, filePath: string) {
const configDir = dirname(entry.path);
const relativeFilePath = normalizePath(relative(configDir, filePath));
if (relativeFilePath.startsWith('../') || relativeFilePath === '..') {
return false;
}

const files = asStringArray(entry.config.files);
if (files.length > 0) {
return files.some((candidatePath) => resolve(configDir, candidatePath) === filePath);
}

const includePatterns = getIncludePatterns(entry.config);
if (includePatterns.length === 0) {
return false;
}

const excludePatterns = [...DEFAULT_EXCLUDE_PATTERNS, ...asStringArray(entry.config.exclude)].map(
normalizeTsconfigPattern
);

if (matchesPatterns(relativeFilePath, excludePatterns)) {
return false;
}

return matchesPatterns(relativeFilePath, includePatterns);
}

function getIncludePatterns(config: TsconfigConfig) {
const includes = asStringArray(config.include);
if (includes.length > 0) {
return includes.map(normalizeTsconfigPattern);
}

const files = asStringArray(config.files);
if (files.length > 0) {
return files.map(normalizePath);
}

if (Array.isArray(config.references) && files.length === 0) {
return [];
}

return ['**/*'];
}

function compareTsconfigEntries(filePath: string, left: TsconfigEntry, right: TsconfigEntry) {
const leftDir = dirname(left.path);
const rightDir = dirname(right.path);
const leftDepth = normalizePath(relative(leftDir, filePath)).split('/').length;
const rightDepth = normalizePath(relative(rightDir, filePath)).split('/').length;

if (leftDepth !== rightDepth) {
return leftDepth - rightDepth;
}

const leftIsPrimaryTsconfig = basename(left.path) === 'tsconfig.json' ? 1 : 0;
const rightIsPrimaryTsconfig = basename(right.path) === 'tsconfig.json' ? 1 : 0;

return rightIsPrimaryTsconfig - leftIsPrimaryTsconfig;
}

function readTsconfigConfig(configPath: string): TsconfigConfig | undefined {
try {
const content = readFileSync(configPath, 'utf-8');
return JSON.parse(stripJsonComments(content)) as TsconfigConfig;
} catch {
return undefined;
}
}

function matchesPatterns(filePath: string, patterns: string[]) {
return patterns.some((pattern) => picomatch(pattern, { dot: true })(filePath));
}

function normalizeTsconfigPattern(pattern: string) {
const normalizedPattern = normalizePath(pattern);
if (normalizedPattern.endsWith('/')) {
return `${normalizedPattern}**/*`;
}

if (!picomatch.scan(normalizedPattern).isGlob && !/\.[^/]+$/.test(normalizedPattern)) {
return `${normalizedPattern}/**/*`;
}

return normalizedPattern;
Comment thread
kasperpeulen marked this conversation as resolved.
}

function normalizePath(value: string) {
return value.replaceAll('\\', '/');
}

function asStringArray(value: unknown) {
return Array.isArray(value)
? value.filter((item): item is string => typeof item === 'string')
: [];
}
39 changes: 18 additions & 21 deletions code/frameworks/react-vite/src/plugins/react-docgen.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { existsSync } from 'node:fs';
import { relative, sep } from 'node:path';
import { dirname, relative, sep } from 'node:path';

import { getProjectRoot } from 'storybook/internal/common';
import { findTsconfigPathForFile } from 'storybook/internal/common';
import { logger } from 'storybook/internal/node-logger';

import { createFilter } from '@rollup/pluginutils';
import * as find from 'empathic/find';
import MagicString from 'magic-string';
import type { Documentation } from 'react-docgen';
import {
Expand Down Expand Up @@ -44,24 +43,6 @@ export async function reactDocgen({
const cwd = process.cwd();
const filter = createFilter(include, exclude);

const projectRoot = getProjectRoot();
const tsconfigPath =
find.up('tsconfig.json', { cwd, last: projectRoot }) ??
find.up('tsconfig.base.json', { cwd, last: projectRoot }) ??
find.up('tsconfig.app.json', { cwd, last: projectRoot });
const tsconfig = TsconfigPaths.loadConfig(tsconfigPath);

let matchPath: TsconfigPaths.MatchPath | undefined;

if (tsconfig.resultType === 'success') {
logger.debug('Using tsconfig paths for react-docgen');
matchPath = TsconfigPaths.createMatchPath(tsconfig.absoluteBaseUrl, tsconfig.paths, [
'browser',
'module',
'main',
]);
}

return {
name: 'storybook:react-docgen-plugin',
enforce: 'pre',
Expand All @@ -71,6 +52,7 @@ export async function reactDocgen({
}

try {
const matchPath = createTsconfigMatchPath(id);
const docgenResults = parse(src, {
resolver: defaultResolver,
handlers,
Expand Down Expand Up @@ -133,3 +115,18 @@ export function getReactDocgenImporter(matchPath: TsconfigPaths.MatchPath | unde
throw new ReactDocgenResolveError(filename);
});
}

function createTsconfigMatchPath(filePath: string) {
const tsconfig = TsconfigPaths.loadConfig(findTsconfigPathForFile(dirname(filePath), filePath));

if (tsconfig.resultType !== 'success') {
return undefined;
}

logger.debug('Using tsconfig paths for react-docgen');
return TsconfigPaths.createMatchPath(tsconfig.absoluteBaseUrl, tsconfig.paths, [
'browser',
'module',
'main',
]);
}
Loading
Loading