0.0.7 • Published 1 month ago

@tchwrks/storybook-text-search v0.0.7

Weekly downloads
-
License
MIT
Repository
github
Last release
1 month ago

@tchwrks/storybook-text-search is a Storybook addon that enables full-text search across MDX content:

  • šŸ” Builds an MDX/documentation index at startup (Stories / autodocs parsing on the roadmap)
  • šŸ’” Integrates a "search bar" toolbar trigger and modern search modal into the Storybook manager UI
  • ā™»ļø Automatically rebuilds index on hot reload
  • šŸŽ¹ 100% keyboard accessible
  • āš™ļø Works with both Vite and Webpack Storybook builders
  • āœļø Written in (mostly) TypeScript using the official Addon Kit
  • 🐭 Sub 200KB index for >20 MDX files
  • šŸŽļø Fast local search. No server needed. No 3-5 business days for results

āš ļø Note: This addon has not performed the Storybook 9.0 migrations yet. A new major version supporting Storybook 9.0 will be released soon. This addon also does not currently support the React Native framework. Limited functionality for non-react frameworks

Table of Contents

Getting Started

Installation

First, install the addon:

yarn add -D @tchwrks/storybook-text-search
# or
npm install --save-dev @tchwrks/storybook-text-search

# Optionally, to install and register the addon in .storybook/main#addons
yarn dlx storybook add @tchwrks/storybook-text-search
# or
npx storybook add @tchwrks/storybook-text-search

Configuration

"Trust the process"

This addon ships with an initialization script to get you going:

npx storybook-text-search-init

This script will:

  • Generate .storybook-text-search which houses the config and artifact(s)
  • Read your .storybook/main.{ts,js} and generate a default .storybook-text-search/config.js with sensible defaults
  • Register the addon and its artifacts in .storybook/main.{ts,js}
  • Generate an initial index.

For most setups, this is all you need to get going. Just start Storybook like usual and enjoy!

"I'll do it myself"

  1. Create the tool / addon directory, .storybook-text-search/ at your project's root (or at the same level as your .storybook/

  2. Create a .storybook-text-search/config.mjs:

    // Custom extractors. See `advanced configuration` for more
    
    import {
      tableExtractor,
      colorPaletteExtractor,
      apiEndpointExtractor,
      typeScaleExtractor
    } from "./custom-extractors/index.js";
    
    const config = {
      // inputPaths ā‰ˆ stories from .storybook/main.{ts,js}. Glob ext should be written like example
      // ...for single dir, still place inside an array
      inputPaths: ["../src/stories/**/*.mdx", "../src/**/*.stories.{js,jsx,ts,tsx}"],
      // Recursion level/component depth for jsx parsing
      maxJsxDepth: 5,
      // Props to parse for fallback jsx parse (opt for jsxTextMap entry when possible)
      jsxPropAllowList: ['alt', 'title', 'aria-label', 'placeholder', 'label', 'value', 'children'],
    };
    
    export default config;

    Customize this as needed to index the right files and extract meaningful metadata for your docs. See advanced configuration for more

  3. Create .storybook-text-search/artifacts directory

  4. Regardless of whether you used storybook add for install, make sure you register the addon + artifacts dir from the previous step in your .storybook/main.{ts,js}:

    // .storybook/main.ts (React + Vite)
    
    import type { StorybookConfig } from "@storybook/react-vite";
    const config: StorybookConfig = {
      stories: ["../src/stories/**/*.mdx", "../src/**/*.stories.@(js|jsx|ts|tsx)"],
      addons: [
        "@storybook/addon-links",
        "@storybook/addon-essentials",
        "@storybook/addon-interactions",
        // Register addon. Order determines toolbar placement (last = right)
        // ...running `storybook add` automatically registers addon
        "@tchwrks/storybook-text-search"
      ],
      // Register artifacts dir. Order does not matter
      staticDirs: ['../.storybook-text-search/artifacts', '../src/assets'],
      framework: {
        name: "@storybook/react-vite",
        options: {},
      },
      docs: {
        autodocs: "tag",
      },
      core: {
        builder: '@storybook/builder-vite',
      }
    };
    
    export default config;
  5. Finally, run npx storybook-text-search-build or start your Storybook (index will build during startup)

Usage

Usage of this addon is pretty straightforward once you set it up. Just add *.mdx docs into the paths you defined in your .storybook-text-search/config.mjs and they'll be automatically indexed on Storybook startup and hot reload.

When Storybook is running, you can open the search modal by clicking the trigger in the toolbar labeled "Find MDX Text" or by using the keyboard shortcut Shift + Cmd + K. Once open, you can use your mouse/trackpad or the following keyboard shortcuts to navigate:

ShortcutAction
Shift + Cmd + KOpen search modal
↑ / ↓ or TabNavigate between results
EnterJump to selected result
EscClose the search modal

You can adjust the positioning of the search modal trigger / search bar by adjusting where you place @tchwrks/storybook-text-search in your .storybook/main addon array. Beginning of array = leftmost toolbar spot, end of array = rightmost toolbar spot.

While the addon can pick up on raw markdown and parse basic text rendered in JSX as children or props by leveraging just the maxJsxDepth and jsxPropsAllowList fields in the config.mjs, you may find that it does not fully parse or normalize the text. In which case, please consider leveraging the jsxTextMap field in the config.mjs. It allows you to map out extraction behavior for JSX on a per-component basis. See Advanced Configuration for further documentation on this.

If for whatever reason you would like to re-run the initialization script or manually trigger an index rebuild, you can run either of the following scripts:

npx storybook-search-init  # addon initialization

npx storybook-search-build-index  # build index

A hosted Storybook with expanded documentation and a live demo will be released soon

Note on Framework Compatibility

This addon works with all Storybook frameworks that support MDX, including React, Vue, and Angular.

However, advanced text extraction from JSX components (like parsing strings from nested props or child elements) is currently optimized for React-style JSX. If you're using Vue or Angular, the addon will still index standard Markdown and inline HTML correctly — just avoid framework-specific bindings or dynamic components in .mdx files.

Tip: For the best results in Vue or Angular or other non-react frameworks, write MDX with plain HTML/Markdown content. Full JSX-style extraction for other frameworks may be added in the future. If you are interested in accelerating that timeline, reach out.

Advanced Configuration

As mentioned in the Usage section, this addon does a pretty decent job of parsing text for most basic *.mdx files. However, MDX files can contain JSX, and that JSX might render text at any arbitrary depth and through non-string props. That's where the jsxTextMap field in the .storybook-text-search/config.mjs comes into play.

This is where things get lengthy. You've been warned . . .

Full config.mjs Options

FieldTypeDescription
inputPathsstring[]MDX and story globs. Use .{.foo, .bar} for glob extensions
maxJsxDepthnumberRecursion depth for parsing JSX
jsxPropsAllowListstring[]JSX props to extract using default extraction logic
jsxTextMapRecord<string, ExtractionRule>Per-component mapping

Extraction Rule Type

export type ExtractionRule =
  | string[] // prop name shorthand
  | {
    props?: string[]; // prop name shorthand
    children?: boolean; // has children
    nestedTextSelectors?: string[]; // Nested component / element names
  }
  | ((node: any) => string[]); // custom extraction function

Utilizing config.jsxTextMap

A configuration file utilizing custom extraction logic might look like the following

// .storybook-text-search/config.mjs
import {
  tableExtractor,
} from './custom-extractors/index.js';

const config = {
  // inputPaths ā‰ˆ stories from .storybook/main.{ts,js}. Glob ext should be written like example
  // ...for single dir, still place inside an array
  inputPaths: ['../src/stories/**/*.mdx', '../src/**/*.stories.{js,jsx,ts,tsx}'],
  // Recursion level/component depth for jsx parsing
  maxJsxDepth: 5,
  // Props to parse for fallback jsx parse (opt for jsxTextMap entry when possible)
  jsxPropAllowList: ['alt', 'title', 'aria-label', 'placeholder', 'label', 'value', 'children'],
  // Custom / more explicit parsing
  jsxTextMap: {
    // Shorthand (similar to jsxPropAllowList, just scoped)
    Badge: ['callout'],
    // Example: <TextBlock>Text here</TextBlock>
    TextBlock: ['children'],
    // Useful for if you have a complex nesting of plain string props
    // Example: <Callout message="..." >Text</Callout>
    Callout: {
      props: ['message'],
        children: true,
        nestedTextSelectors: ['BodyText'],
    },
    // For when sh*t gets hairy
    Table: {
      extractor: tableExtractor // (node) => string[]
    },
};

export default config;

When defining ExtractionRule functions (Table from the code above), it is recommended you write them as plain *.js files. ESM vs CJS shouldn't make a difference--but you likely won't need extra deps anyway.

Given an a MDX file that contains a Table component that looks like the following:

import React from "react";

type Column<T> = {
    key: keyof T;
    header: string;
    render?: (value: T[keyof T], row: T) => React.ReactNode;
};

type Props<T> = {
    columns: Column<T>[];
    data: T[];
    className?: string;
};

export function Table<T extends Record<string, any>>({ columns, data, className }: Props<T>) {
    return (
        <table className={className} style={{ width: "100%", borderCollapse: "collapse" }}>
            <thead>
                <tr>
                    {columns.map((col, i) => (
                        <th
                            key={i}
                            style={{ borderBottom: "1px solid #ddd", textAlign: "left", padding: "8px" }}
                        >
                            {col.header}
                        </th>
                    ))}
                </tr>
            </thead>
            <tbody>
                {data.map((row, rowIndex) => (
                    <tr key={rowIndex}>
                        {columns.map((col, colIndex) => (
                            <td
                                key={colIndex}
                                style={{ borderBottom: "1px solid #f0f0f0", padding: "8px" }}
                            >
                                {col.render ? col.render(row[col.key], row) : row[col.key]}
                            </td>
                        ))}
                    </tr>
                ))}
            </tbody>
        </table>
    );
}

... a custom ExtractionRule function for that component might look like this:

// .storybook-text-search/custom-extractors (or wherever you want to put it--knock yourself out)

export function tableExtractor(node) {
    const strings = [];

    const columnsAttr = node.attributes?.find(attr => attr.name === 'columns');
    const dataAttr = node.attributes?.find(attr => attr.name === 'data');

    const extractFromArrayExpression = (valueNode) => {
        if (valueNode?.type !== 'mdxJsxAttributeValueExpression' || typeof valueNode.value !== 'string') return [];

        try {
            const evalFunc = new Function(`return (${valueNode.value})`);
            const parsed = evalFunc();

            if (Array.isArray(parsed)) {
                return parsed;
            }
        } catch (err) {
            console.warn('āš ļø Failed to parse Table attribute value:', err);
        }

        return [];
    };

    const columns = extractFromArrayExpression(columnsAttr?.value);
    const data = extractFromArrayExpression(dataAttr?.value);

    for (const col of columns) {
        if (typeof col?.header === 'string') {
            strings.push(col.header);
        }
    }

    for (const row of data) {
        for (const key in row) {
            if (typeof row[key] === 'string') {
                strings.push(row[key]);
            }
        }
    }

    return strings;
}

Want to get back to where you were before? I got you

About

Built by Techworks Studio

Techworks Studio is a hybrid consultancy and R&D studio crafting tools and solutions that empower bold ideas, redefine workflows, and deliver meaningful user experiences

Other goodies from Techworks

  • tokenXtractor - Free Figma plugin for extracting local variables (tokens) into a more code-ready format. Control which collections get exported and how
  • Figma Plugin React Template (2024) - TS template for creating Figma plugins with a React for the UI

Connect

This addon is built and maintained by Noah Davis of Techworks Studio

Have feedback, ideas, or a collaboration in mind? Reach out anytime

License

MIT

Roadmap

  • Storybook 9.0 migration + major bump
  • Demo-embedded docs
  • React: Parse stories + component definitions (for autodocs)
  • Advanced searches (multi-keyword, boolean operations, etc)
  • Expanded config

Ready to stop playing Where's Wally with your Storybook docs?

yarn add -D @tchwrks/storybook-text-search
# or
npm i -D @tchwrks/storybook-text-search

npx storybook-search-init

Yup. Up and running in just 2 commands . . .