はじめに
- storybookのTags機能について有効活用を考えたい
- storyの数が多くなってきているので、フィルタして対象有無が判別しやすくしたい
Tag機能とは
Tags は、ストーリーやコンポーネントに任意の文字列ラベル(タグ)を付与する仕組み。ストーリーのフィルタリングや表示制御などに使える。
すべての Storybook プロジェクトにはデフォルトで以下のタグが存在します。
- dev:サイドバーに表示されるストーリーに自動的に付与
- test:テスト実行ランナー(Vitest 等)に含められるストーリーに自動的に付与
- autodocs:これをタグ付けされたストーリーがある場合、ドキュメントページが自動生成される
参考記事: https://storybook.js.org/blog/storybook-tags/
やってみる
LoginPage.stories.tsxにtagをつけてみます。タグ:feature:auth
import type { Meta, StoryObj } from '@storybook/react-vite';
import { LoginFlow, LoginFlowWrapper } from './LoginFlow';
import { PasswordResetForm } from './components/PasswordResetForm';
import { TwoFactorForm } from './components/TwoFactorForm';
import { LoginForm } from './components/LoginForm';
import { evaluateLoginAttempt, loginStoryActions } from './storyHelpers';
const meta: Meta = {
title: 'Pages/Login/LoginScenarios',
tags: ['feature:auth'], // ← tagの記述
parameters: {
layout: 'fullscreen',
},
};
export default meta;
type Story = StoryObj;
export const PAG_LGN_FORM: Story = {
name: '[FLOW] ログインフォーム',
render: () => (
<LoginFlowWrapper>
<LoginForm
onLoginAttempt={evaluateLoginAttempt}
onLoginSuccess={loginStoryActions.loginSubmit}
onPasswordReset={loginStoryActions.requestReset}
/>
</LoginFlowWrapper>
),
};
するとフィルタできるようになりました。やったぜ。
タグづけするスクリプトをつくる
すべてのstoryにタグづけるのはだるいので簡易自動化スクリプトをつくりました。
これでハッピー。やったぜ。
add-tags.mjs
#!/usr/bin/env node
import { Project, SyntaxKind } from 'ts-morph';
import path from 'node:path';
import fs from 'node:fs';
const CATEGORY_DIRS = new Set([
'inputs',
'data-display',
'navigation',
'feedback',
'surfaces',
'forms',
'dialogs',
'dnd',
'marketing',
'data-input',
'theme',
'i18n',
]);
const FEATURE_MAP_PAGES = {
login: 'auth',
register: 'register',
dashboard: 'dashboard',
helpCenter: 'help-center',
userProfile: 'user-profile',
profile: 'user-profile',
shoppingCartDrawer: 'cart',
adminUserManagement: 'admin-users',
projectRegistration: 'project-registration',
};
const FEATURE_MAP_TEMPLATES = {
auth: 'auth',
dashboard: 'dashboard',
// navigation/todo などは意図的にスキップ
};
function getCategoryTagFromPath(filePath) {
// .../stories/(atoms|molecules|organisms)/<category>/... の形式を想定
const parts = filePath.split(path.sep);
const idx = parts.lastIndexOf('stories');
if (idx === -1) return undefined;
const layer = parts[idx + 1]; // atoms | molecules | organisms | pages | templates | patterns
if (!['atoms', 'molecules', 'organisms'].includes(layer)) return undefined;
const catCandidate = parts[idx + 2];
if (CATEGORY_DIRS.has(catCandidate)) return `category:${catCandidate}`;
// molecules/mobile|perf|a11y などのフォルダは意図的にスキップ
return undefined;
}
function getFeatureTagFromPath(filePath) {
const parts = filePath.split(path.sep);
const idx = parts.lastIndexOf('stories');
if (idx === -1) return undefined;
const layer = parts[idx + 1]; // pages | templates
if (layer === 'pages') {
const pageKey = parts[idx + 2];
const feature = FEATURE_MAP_PAGES[pageKey];
return feature ? `feature:${feature}` : undefined;
}
if (layer === 'templates') {
const templateKey = parts[idx + 2];
const feature = FEATURE_MAP_TEMPLATES[templateKey];
return feature ? `feature:${feature}` : undefined;
}
return undefined;
}
function ensureArrayLiteral(propInit) {
if (propInit == null) return null;
if (propInit.getKind() === SyntaxKind.ArrayLiteralExpression) return propInit;
// 非配列の初期化子を配列に変換
const initText = propInit.getText();
propInit.replaceWithText(`[${initText}]`);
return propInit.getParent().getInitializerIfKind(SyntaxKind.ArrayLiteralExpression) || null;
}
function addTagToMetaObject(objLit, tag) {
const existingProp = objLit.getProperty('tags');
if (existingProp && existingProp.getKind() !== SyntaxKind.PropertyAssignment) {
return false;
}
if (existingProp) {
const propAssign = existingProp;
const init = propAssign.getInitializer();
const arr = ensureArrayLiteral(init);
if (!arr) return false;
const hasTag = arr.getElements().some((el) => el.getText().replace(/['"`]/g, '') === tag);
if (!hasTag) arr.addElement(`'${tag}'`);
return true;
}
// 'component'の後に挿入、存在しない場合は'title'の後、それもない場合は末尾に挿入
const props = objLit.getProperties();
const componentIdx = props.findIndex((p) => p.getKind() === SyntaxKind.PropertyAssignment && p.getName && p.getName() === 'component');
const titleIdx = props.findIndex((p) => p.getKind() === SyntaxKind.PropertyAssignment && p.getName && p.getName() === 'title');
const insertIndex = componentIdx >= 0 ? componentIdx + 1 : titleIdx >= 0 ? titleIdx + 1 : props.length;
objLit.insertPropertyAssignment(insertIndex, { name: 'tags', initializer: `['${tag}']` });
return true;
}
function processFile(sf) {
const filePath = sf.getFilePath();
const isStory = /\.stories\.(t|j)sx?$/.test(filePath);
if (!isStory) return false;
const categoryTag = getCategoryTagFromPath(filePath);
const featureTag = getFeatureTagFromPath(filePath);
const tagToAdd = categoryTag || featureTag;
if (!tagToAdd) return false;
const metaVar = sf.getVariableDeclaration('meta');
if (!metaVar) return false;
const init = metaVar.getInitializer();
if (!init || init.getKind() !== SyntaxKind.ObjectLiteralExpression) return false;
const objLit = init;
const ok = addTagToMetaObject(objLit, tagToAdd);
return ok;
}
async function main() {
const rootTsconfig = path.resolve('tsconfig.json');
const useTsconfig = fs.existsSync(rootTsconfig);
const project = new Project({
tsConfigFilePath: useTsconfig ? rootTsconfig : undefined,
skipAddingFilesFromTsConfig: true,
manipulationSettings: { quoteKind: 'single' },
});
const sources = project.addSourceFilesAtPaths('apps/storybook/src/stories/**/*.stories.@(ts|tsx)');
let changed = 0;
for (const sf of sources) {
try {
if (processFile(sf)) changed++;
} catch (e) {
console.warn('スキップ (エラー):', sf.getFilePath(), e?.message || e);
}
}
await project.save();
console.log(`更新されたファイル数: ${changed}`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});