文件
Storybook 文件

indexers

(⚠️ 實驗性功能)

雖然此功能為實驗性功能,但必須由 StorybookConfigexperimental_indexers 屬性指定。

父層:main.js|ts 設定

類型:(existingIndexers: Indexer[]) => Promise<Indexer[]>

Indexers 負責建構 Storybook 的 stories 索引—所有 stories 的列表及其元資料的子集,例如 idtitletags 等。 索引可以在 Storybook 的 /index.json 路由中讀取。

indexers API 是一項進階功能,可讓您自訂 Storybook 的 indexers,這些 indexers 指定 Storybook 如何將檔案編入索引並解析為 story 條目。 這為您撰寫 stories 的方式增加了更多彈性,包括定義 stories 的語言或從何處取得 stories。

它們被定義為一個函數,該函數返回完整的 indexers 列表,包括現有的 indexers。 這允許您將自己的 indexer 新增到列表中,或替換現有的 indexer

.storybook/main.ts
// Replace your-framework with the framework you are using (e.g., react-webpack5, vue3-vite)
import type { StorybookConfig } from '@storybook/your-framework';
 
const config: StorybookConfig = {
  framework: '@storybook/your-framework',
  stories: [
    '../src/**/*.mdx',
    '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)',
    // 👇 Make sure files to index are included in `stories`
    '../src/**/*.custom-stories.@(js|jsx|ts|tsx)',
  ],
  experimental_indexers: async (existingIndexers) => {
    const customIndexer = {
      test: /\.custom-stories\.[tj]sx?$/,
      createIndex: async (fileName) => {
        // See API and examples below...
      },
    };
    return [...existingIndexers, customIndexer];
  },
};
 
export default config;

除非您的 indexer 正在執行相對簡單的操作(例如使用不同的命名慣例為 stories 編製索引),否則除了為檔案編製索引外,您可能還需要將其轉譯為 CSF,以便 Storybook 可以在瀏覽器中讀取它們。

Indexer

類型

{
  test: RegExp;
  createIndex: (fileName: string, options: IndexerOptions) => Promise<IndexInput[]>;
}

指定要索引哪些檔案以及如何將它們索引為 stories。

test

(必填)

類型:RegExp

針對包含在 stories 設定中的檔案名稱執行的正則表達式,應符合此 indexer 要處理的所有檔案。

createIndex

(必填)

類型:(fileName: string, options: IndexerOptions) => Promise<IndexInput[]>

接受單個 CSF 檔案並傳回要索引的條目列表的函數。

fileName

類型:string

用於建立索引條目的 CSF 檔案名稱。

IndexerOptions

類型

{
  makeTitle: (userTitle?: string) => string;
}

用於索引檔案的選項。

makeTitle

類型:(userTitle?: string) => string

一個函數,它接受使用者提供的標題,並傳回索引條目的格式化標題,該標題用於側邊欄。 如果未提供使用者標題,則會根據檔案名稱和路徑自動產生一個標題。

有關範例用法,請參閱 IndexInput.title

IndexInput

類型

{
  exportName: string;
  importPath: string;
  type: 'story';
  rawComponentPath?: string;
  metaId?: string;
  name?: string;
  tags?: string[];
  title?: string;
  __id?: string;
}

代表要新增至 stories 索引的 story 的物件。

exportName

(必填)

類型:string

對於每個 IndexInput,indexer 都會將此匯出(來自 importPath 中找到的檔案)新增為索引中的條目。

importPath

(必填)

類型:string

要從中導入的檔案,例如 CSF 檔案。

被索引的fileName 很可能不是 CSF,在這種情況下,您需要將其轉譯為 CSF,以便 Storybook 可以在瀏覽器中讀取它。

type

(必填)

類型:'story'

條目的類型。

rawComponentPath

類型:string

提供 meta.component 的檔案的原始路徑/套件(如果存在)。

metaId

類型:string

預設值:從 title 自動產生

定義條目元資料的自訂 ID。

如果指定,CSF 檔案中的 export default (meta) 必須具有對應的 id 屬性,才能正確匹配。

name

類型:string

預設值:從 exportName 自動產生

條目的名稱。

tags

類型:string[]

用於在 Storybook 及其工具中篩選條目的標籤。

title

類型:string

預設值:從 importPath 的預設匯出自動產生

決定條目在側邊欄中的位置。

在大多數情況下,您應該不要指定標題,以便您的 indexer 將使用預設的命名行為。 在指定標題時,您必須使用 IndexerOptions 中提供的 makeTitle 函數,以便也使用此行為。 例如,以下是一個 indexer,它僅將 "Custom" 前綴附加到從檔案名稱派生的標題中

.storybook/main.ts
// Replace your-framework with the framework you are using (e.g., react-webpack5, vue3-vite)
import type { StorybookConfig } from '@storybook/your-framework';
import type { Indexer } from '@storybook/types';
 
const combosIndexer: Indexer = {
  test: /\.stories\.[tj]sx?$/,
  createIndex: async (fileName, { makeTitle }) => {
    // 👇 Grab title from fileName
    const title = fileName.match(/\/(.*)\.stories/)[1];
 
    // Read file and generate entries ...
    const entries = [];
 
    return entries.map((entry) => ({
      type: 'story',
      // 👇 Use makeTitle to format the title
      title: `${makeTitle(title)} Custom`,
      importPath: fileName,
      exportName: entry.name,
    }));
  },
};
 
const config: StorybookConfig = {
  framework: '@storybook/your-framework',
  stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'],
  experimental_indexers: async (existingIndexers) => [...existingIndexers, combosIndexer],
};
 
export default config;
__id

類型:string

預設值:從 title/metaIdexportName 自動產生

定義條目的 story 的自訂 ID。

如果指定,CSF 檔案中的 story 必須具有對應的 __id 屬性,才能正確匹配。

僅當您需要覆寫自動產生的 ID 時才使用此選項。

轉譯為 CSF

IndexInput 中,importPath 的值必須解析為 CSF 檔案。 然而,大多數自訂 indexers 僅在輸入不是 CSF 時才是必要的。 因此,您可能需要將輸入轉譯為 CSF,以便 Storybook 可以在瀏覽器中讀取它並渲染您的 stories。

將自訂來源格式轉譯為 CSF 超出了本文檔的範圍。 這種轉譯通常在建置器層級 (Vite 和/或 Webpack) 完成,我們建議使用 unplugin 為多個建置器建立外掛程式。

一般架構如下所示

Architecture diagram showing how a custom indexer indexes stories from a source file

  1. 使用 stories 設定,Storybook 會找到所有符合您的 indexer 的 test 屬性的檔案
  2. Storybook 將每個符合的檔案傳遞給您的 indexer 的 createIndex 函數,該函數使用檔案內容產生並傳回要新增到索引的索引條目(stories)列表
  3. 索引會填充 Storybook UI 中的側邊欄

Architecture diagram showing how a build plugin transforms a source file into CSF

  1. 在 Storybook UI 中,使用者導航到與 story ID 相符的 URL,並且瀏覽器請求索引條目的 importPath 屬性指定的 CSF 檔案
  2. 回到伺服器上,您的建置器外掛程式將來源檔案轉譯為 CSF,並將其提供給用戶端
  3. Storybook UI 讀取 CSF 檔案,導入 exportName 指定的 story,並渲染它

讓我們看看一個例子,說明這可能如何運作。

首先,這是一個非 CSF 來源檔案的範例

// Button.variants.js|ts
 
import { variantsFromComponent, createStoryFromVariant } from '../utils';
import { Button } from './Button';
 
/**
 * Returns raw strings representing stories via component props, eg.
 * 'export const PrimaryVariant = {
 *    args: {
 *      primary: true
 *    },
 *  };'
 */
export const generateStories = () => {
  const variants = variantsFromComponent(Button);
  return variants.map((variant) => createStoryFromVariant(variant));
};

然後,建置器外掛程式將

  1. 接收並讀取來源檔案
  2. 導入匯出的 generateStories 函數
  3. 執行該函數以產生 stories
  4. 將 stories 寫入 CSF 檔案

然後,產生的 CSF 檔案將由 Storybook 編製索引。 它看起來會像這樣

// virtual:Button.variants.js|ts
 
import { Button } from './Button';
 
export default {
  component: Button,
};
 
export const Primary = {
  args: {
    primary: true,
  },
};

範例

自訂 indexers 的一些範例用法包括

從夾具資料或 API 端點動態產生 stories

此 indexer 根據 JSON 夾具資料為組件產生 stories。 它在專案中尋找 *.stories.json 檔案,將它們新增到索引,並將其內容單獨轉換為 CSF。

.storybook/main.ts
// Replace your-framework with the framework you are using (e.g., react-webpack5, vue3-vite)
import type { StorybookConfig } from '@storybook/your-framework';
import type { Indexer } from '@storybook/types';
 
import fs from 'fs/promises';
 
const jsonStoriesIndexer: Indexer = {
  test: /stories\.json$/,
  createIndex: async (fileName) => {
    const content = JSON.parse(fs.readFileSync(fileName));
 
    const stories = generateStoryIndexesFromJson(content);
 
    return stories.map((story) => ({
      type: 'story',
      importPath: `virtual:jsonstories--${fileName}--${story.componentName}`,
      exportName: story.name,
    }));
  },
};
 
const config: StorybookConfig = {
  framework: '@storybook/your-framework',
  stories: [
    '../src/**/*.mdx',
    '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)',
    // 👇 Make sure files to index are included in `stories`
    '../src/**/*.stories.json',
  ],
  experimental_indexers: async (existingIndexers) => [...existingIndexers, jsonStoriesIndexer],
};
 
export default config;

輸入 JSON 檔案的範例可能如下所示

{
  "Button": {
    "componentPath": "./button/Button.jsx",
    "stories": {
      "Primary": {
        "args": {
          "primary": true
        },
      "Secondary": {
        "args": {
          "primary": false
        }
      }
    }
  },
  "Dialog": {
    "componentPath": "./dialog/Dialog.jsx",
    "stories": {
      "Closed": {},
      "Open": {
        "args": {
          "isOpen": true
        }
      },
    }
  }
}

然後,建置器外掛程式需要將 JSON 檔案轉換為常規 CSF 檔案。 此轉換可以使用類似於此的 Vite 外掛程式來完成

// vite-plugin-storybook-json-stories.ts
 
import type { PluginOption } from 'vite';
import fs from 'fs/promises';
 
function JsonStoriesPlugin(): PluginOption {
  return {
    name: 'vite-plugin-storybook-json-stories',
    load(id) {
      if (!id.startsWith('virtual:jsonstories')) {
        return;
      }
 
      const [, fileName, componentName] = id.split('--');
      const content = JSON.parse(fs.readFileSync(fileName));
 
      const { componentPath, stories } = getComponentStoriesFromJson(content, componentName);
 
      return `
        import ${componentName} from '${componentPath}';
 
        export default { component: ${componentName} };
 
        ${stories.map((story) => `export const ${story.name} = ${story.config};\n`)}
      `;
    },
  };
}
使用替代 API 產生 stories

您可以使用自訂 indexer 和建置器外掛程式來建立您的 API,以定義擴展 CSF 格式的 stories。 若要瞭解更多資訊,請參閱以下概念驗證,以設定自訂 indexer 以動態產生 stories。 它包含支援此功能所需的一切,包括 indexer、Vite 外掛程式和 Webpack 載入器。

以非 JavaScript 語言定義 stories

自訂 indexers 可用於進階用途:以任何語言(包括範本語言)定義 stories,並將檔案轉換為 CSF。 若要查看實際範例,您可以參考 @storybook/addon-svelte-csf 以取得 Svelte 範本語法,以及 storybook-vue-addon 以取得 Vue 範本語法。

從 URL 集合新增側邊欄連結

indexer API 足夠靈活,可讓您處理任意內容,只要您的框架工具可以將該內容中的匯出轉換為它可以執行的實際 stories 即可。 這個進階範例示範了如何建立自訂 indexer 來處理 URL 集合、從每個頁面中提取標題和 URL,並將它們渲染為 UI 中的側邊欄連結。 它以 Svelte 實作,可以適用於任何框架。

首先建立 URL 集合檔案(即 src/MyLinks.url.js),其中 URL 列表列為具名匯出。 indexer 將使用匯出名稱作為 story 標題,並使用該值作為唯一識別碼。

MyLinks.url.js
export default {};
 
export const DesignTokens = 'https://www.designtokens.org/';
export const CobaltUI = 'https://cobalt-ui.pages.dev/';
export const MiseEnMode = 'https://mode.place/';
export const IndexerAPI = 'https://github.com/storybookjs/storybook/discussions/23176';

調整您的 Vite 設定檔,以包含補充 indexer 的自訂外掛程式。 這將允許 Storybook 處理 URL 集合檔案並將其作為 stories 導入。

vite.config.ts
import * as acorn from 'acorn';
import * as walk from 'acorn-walk';
import { defineConfig, type Plugin } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
 
function StorybookUrlLinksPlugin(): Plugin {
  return {
    name: 'storybook-url-links',
    async transform(code: string, id: string) {
      if (id.endsWith('.url.js')) {
        const ast = acorn.parse(code, {
          ecmaVersion: 2020,
          sourceType: 'module',
        });
 
        const namedExports: string[] = [];
        let defaultExport = 'export default {};';
 
        walk.simple(ast, {
          // Extracts the named exports, those represent our stories, and for each of them, we'll return a valid Svelte component.
          ExportNamedDeclaration(node: acorn.ExportNamedDeclaration) {
            if (
              node.declaration &&
              node.declaration.type === 'VariableDeclaration'
            ) {
              node.declaration.declarations.forEach((declaration) => {
                if ('name' in declaration.id) {
                  namedExports.push(declaration.id.name);
                }
              });
            }
          },
          // Preserve our default export.
          ExportDefaultDeclaration(node: acorn.ExportDefaultDeclaration) {
            defaultExport = code.slice(node.start, node.end);
          },
        });
 
        return {
          code: `
            import RedirectBack from '../../.storybook/components/RedirectBack.svelte';
            ${namedExports
              .map(
                (name) =>
                  `export const ${name} = () => new RedirectBack();`
              )
              .join('\n')}
            ${defaultExport}
          `,
          map: null,
        };
      }
    },
  };
}
 
export default defineConfig({
  plugins: [StorybookUrlLinksPlugin(), svelte()],
})

更新您的 Storybook 設定(即 .storybook/main.js|ts)以包含自訂 indexer。

.storybook/main.js|ts
import type { StorybookConfig } from '@storybook/svelte-vite';
import type { Indexer } from '@storybook/types';
 
const urlIndexer: Indexer = {
  test: /\.url\.js$/,
  createIndex: async (fileName, { makeTitle }) => {
    const fileData = await import(fileName);
 
    return Object.entries(fileData)
      .filter(([key]) => key != 'default')
      .map(([name, url]) => {
        return {
          type: 'docs',
          importPath: fileName,
          exportName: name,
          title: makeTitle(name)
            .replace(/([a-z])([A-Z])/g, '$1 $2')
            .trim(),
          __id: `url--${name}--${encodeURIComponent(url as string)}`,
          tags: ['!autodocs', 'url']
        };
      });
  }
};
 
const config: StorybookConfig = {
  stories: ['../src/**/*.stories.@(js|ts|svelte)', '../src/**/*.url.js'],
  framework: {
    name: '@storybook/svelte-vite',
    options: {},
  },
  experimental_indexers: async (existingIndexers) => [urlIndexer, ...existingIndexers]
};
export default config;

新增 Storybook UI 設定檔(即 .storybook/manager.js|ts)以將索引的 URL 渲染為 UI 中的側邊欄連結

.storybook/manager.ts
import { addons } from '@storybook/manager-api';
 
import SidebarLabelWrapper from './components/SidebarLabelWrapper.tsx';
 
addons.setConfig({
    sidebar: {
      renderLabel: (item) => SidebarLabelWrapper({ item }),
    },
});

此範例的程式碼和即時示範可在 StackBlitz 上取得。