Skip to content

Commit dac45f5

Browse files
authored
docs(Storybook): add docs search addon (#8501)
For testing, Storybook needs to be build: `yarn build:storybook && npx serve .out`
1 parent eb914ef commit dac45f5

10 files changed

Lines changed: 425 additions & 2 deletions

File tree

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { SearchIcon } from '@storybook/icons';
2+
import * as React from 'react';
3+
import { IconButton } from 'storybook/internal/components';
4+
import { addons, types } from 'storybook/manager-api';
5+
6+
const ADDON_ID = 'content-search';
7+
const TOOL_ID = `${ADDON_ID}/toolbar`;
8+
const isMac = navigator.platform.toUpperCase().includes('MAC');
9+
const shortcut = isMac ? '⌘⇧F' : 'Ctrl+Shift+F';
10+
11+
function SearchButton() {
12+
const openSearch = React.useCallback(() => {
13+
const dialog = document.getElementById('pagefind-search-container');
14+
if (dialog && dialog instanceof HTMLDialogElement && !dialog.open) {
15+
dialog.showModal();
16+
setTimeout(() => dialog.querySelector<HTMLInputElement>('.pagefind-ui__search-input')?.focus(), 50);
17+
}
18+
}, []);
19+
20+
React.useEffect(() => {
21+
const handleKeydown = (e: KeyboardEvent) => {
22+
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'f') {
23+
e.preventDefault();
24+
openSearch();
25+
}
26+
};
27+
document.addEventListener('keydown', handleKeydown);
28+
return () => document.removeEventListener('keydown', handleKeydown);
29+
}, [openSearch]);
30+
31+
return (
32+
<IconButton key={TOOL_ID} title={`Search docs (${shortcut})`} style={{ order: -2 }} onClick={openSearch}>
33+
<SearchIcon />
34+
Search Docs (Beta)
35+
</IconButton>
36+
);
37+
}
38+
39+
addons.register(ADDON_ID, () => {
40+
addons.add(TOOL_ID, {
41+
type: types.TOOLEXTRA,
42+
title: 'Search documentation content',
43+
render: () => <SearchButton />,
44+
});
45+
});
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
#pagefind-search-container {
2+
border: none;
3+
padding: 1rem;
4+
max-width: 80vw;
5+
max-height: 80vh;
6+
width: 100%;
7+
overflow-y: auto;
8+
background: var(--sapBackgroundColor);
9+
border-radius: var(--sapElement_BorderCornerRadius);
10+
box-shadow: var(--sapContent_Shadow3);
11+
}
12+
#pagefind-search-container::backdrop {
13+
background: rgba(0, 0, 0, 0.5);
14+
}
15+
#pagefind-search-container .pagefind-ui__result-link {
16+
color: var(--sapLinkColor);
17+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/* global PagefindUI */
2+
3+
const container = document.getElementById('pagefind-search-container');
4+
5+
if (container) {
6+
try {
7+
new PagefindUI({
8+
element: '#pagefind-ui',
9+
showSubResults: true,
10+
showImages: false,
11+
showEmptyFilters: false,
12+
});
13+
} catch {
14+
// PagefindUI may not be available in dev mode (index only exists after build)
15+
}
16+
17+
container.addEventListener('click', (e) => {
18+
if (e.target === container) container.close();
19+
});
20+
}

.storybook/main.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ const config: StorybookConfig = {
8181
shouldRemoveUndefinedFromOptional: true,
8282
},
8383
},
84-
staticDirs: isDevMode ? ['images-dev'] : ['images'],
84+
staticDirs: isDevMode ? ['images-dev', 'content-search'] : ['images', 'content-search'],
8585
viteFinal: (viteConfig) =>
8686
mergeConfig(viteConfig, {
8787
build: {

.storybook/manager-head.html

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,13 @@
5252
}
5353
</script>
5454
<link rel="icon" href="favicon.ico" />
55+
56+
<!-- Pagefind content search (index built by scripts/storybook-search/build-index.ts) -->
57+
<link href="./pagefind/pagefind-ui.css" rel="stylesheet">
58+
<link href="./content-search.css" rel="stylesheet">
59+
60+
<dialog id="pagefind-search-container" aria-label="Search documentation">
61+
<div id="pagefind-ui"></div>
62+
</dialog>
63+
<script src="./pagefind/pagefind-ui.js" type="module"></script>
64+
<script src="./content-search.js" type="module"></script>

.storybook/manager.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { addons } from 'storybook/manager-api';
55
import { Badge } from './components/Badge.js';
66
import { Fiori4ReactTheme } from './theme.js';
77
import './components/VersionSwitch.js';
8+
import './components/ContentSearch.js';
89

910
const customTags = new Set(['package:@ui5/webcomponents-react-charts', 'custom']);
1011

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
"start:storybook": "storybook dev -p 6006",
1212
"setup": "lerna run build:i18n && lerna run build:css && lerna run build:css-bundle && lerna run build:version-info && rimraf node_modules/@types/mocha",
1313
"build": "yarn setup && tsc --build tsconfig.build.json && lerna run build:client && lerna run build:wrapper",
14-
"build:storybook": "yarn build && yarn create-cypress-commands-docs && storybook build -o .out",
14+
"build:storybook": "yarn build && yarn create-cypress-commands-docs && storybook build -o .out && node --experimental-strip-types ./scripts/storybook-search/build-index.ts --directory .out",
1515
"build:storybook-sitemap": "node ./scripts/create-storybook-sitemap.js --directory .out",
1616
"test:prepare": "rimraf temp && lerna run build",
1717
"test:open": "CYPRESS_COVERAGE=false cypress open --component --browser chrome",
@@ -98,6 +98,7 @@
9898
"lint-staged": "16.4.0",
9999
"monocart-reporter": "2.10.1",
100100
"npm-run-all2": "8.0.4",
101+
"pagefind": "1.5.2",
101102
"postcss": "8.5.13",
102103
"postcss-cli": "11.0.1",
103104
"postcss-import": "16.1.1",
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
import { readFileSync, existsSync } from 'node:fs';
2+
import { resolve, relative } from 'node:path';
3+
import { parseArgs } from 'node:util';
4+
import { glob } from 'glob';
5+
import * as pagefind from 'pagefind';
6+
import { extractComponentJSDoc, resolveComponentSource } from './extract-comp-description.ts';
7+
8+
interface StoryIndexEntry {
9+
id: string;
10+
type: string;
11+
name: string;
12+
title?: string;
13+
importPath?: string;
14+
tags?: string[];
15+
}
16+
17+
// --- CLI args ---
18+
19+
const { values } = parseArgs({
20+
options: {
21+
directory: { type: 'string', short: 'd' },
22+
},
23+
});
24+
25+
if (typeof values.directory !== 'string') {
26+
throw new Error('Expected --directory to be a string (e.g. --directory .out)');
27+
}
28+
29+
const outDir = resolve(process.cwd(), values.directory);
30+
const indexJsonPath = resolve(outDir, 'index.json');
31+
32+
if (!existsSync(indexJsonPath)) {
33+
throw new Error(`index.json not found at ${indexJsonPath}. Did you run "storybook build" first?`);
34+
}
35+
36+
// --- Read Storybook index.json ---
37+
38+
const storiesJson = JSON.parse(readFileSync(indexJsonPath, 'utf-8'));
39+
const entries: StoryIndexEntry[] = Object.values(storiesJson.entries);
40+
41+
const importPathToEntry = new Map<string, StoryIndexEntry>();
42+
for (const entry of entries) {
43+
if (entry.type === 'docs' && entry.importPath?.endsWith('.mdx')) {
44+
importPathToEntry.set(entry.importPath, entry);
45+
}
46+
}
47+
48+
console.log(`Found ${importPathToEntry.size} docs entries in index.json`);
49+
50+
const mdxFiles = await glob('**/*.mdx', {
51+
cwd: process.cwd(),
52+
ignore: ['node_modules/**', '.out/**', '**/node_modules/**'],
53+
});
54+
55+
console.log(`Found ${mdxFiles.length} MDX files on disk`);
56+
57+
function extractTextFromMdx(source: string): string {
58+
const lines = source.split('\n');
59+
const textParts: string[] = [];
60+
let inCodeBlock = false;
61+
62+
for (const line of lines) {
63+
if (line.trimStart().startsWith('```')) {
64+
inCodeBlock = !inCodeBlock;
65+
continue;
66+
}
67+
68+
if (inCodeBlock) continue;
69+
70+
if (/^\s*import\s/.test(line)) continue;
71+
if (/^\s*export\s/.test(line)) continue;
72+
73+
// Skip JSX-only lines (tags with no text content)
74+
if (/^\s*<[A-Z][\w.]*[\s/>]/.test(line) && !/>([^<]+)</.test(line)) continue;
75+
if (/^\s*<\/[A-Z]/.test(line)) continue;
76+
if (/^\s*<Meta\s/.test(line)) continue;
77+
78+
const headingMatch = line.match(/^(#{1,6})\s+(.+)/);
79+
if (headingMatch) {
80+
textParts.push(headingMatch[2].trim());
81+
continue;
82+
}
83+
84+
const cleaned = line
85+
.replace(/<[^>]+>/g, '')
86+
.replace(/\{`([^`]*)`\}/g, '$1')
87+
.replace(/\{['"]([^'"]*)['"]\}/g, '$1')
88+
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
89+
.replace(/`([^`]+)`/g, '$1')
90+
.replace(/\*{1,3}([^*]+)\*{1,3}/g, '$1')
91+
.replace(/[<>]/g, '')
92+
.trim();
93+
94+
if (cleaned.length > 0) {
95+
textParts.push(cleaned);
96+
}
97+
}
98+
99+
return textParts.join(' ');
100+
}
101+
102+
// --- Extract component descriptions from JSDoc ---
103+
104+
const componentDescriptions = new Map<string, string>();
105+
for (const entry of entries) {
106+
if (entry.type !== 'docs' || !entry.importPath?.endsWith('.mdx')) continue;
107+
if (!entry.tags?.includes('attached-mdx')) continue;
108+
109+
const sourceFile = resolveComponentSource(entry.importPath);
110+
if (!sourceFile) continue;
111+
112+
const description = extractComponentJSDoc(sourceFile);
113+
if (description) {
114+
componentDescriptions.set(entry.importPath, description);
115+
}
116+
}
117+
118+
console.log(`Extracted ${componentDescriptions.size} component descriptions from JSDoc`);
119+
120+
// --- Build Pagefind index ---
121+
122+
const { index } = await pagefind.createIndex();
123+
if (!index) {
124+
throw new Error('Failed to create Pagefind index');
125+
}
126+
127+
let indexed = 0;
128+
let skipped = 0;
129+
130+
for (const mdxFile of mdxFiles) {
131+
const importPath = './' + mdxFile;
132+
const entry = importPathToEntry.get(importPath);
133+
134+
if (!entry) {
135+
skipped++;
136+
continue;
137+
}
138+
139+
const source = readFileSync(mdxFile, 'utf-8');
140+
const mdxText = extractTextFromMdx(source);
141+
142+
const description = componentDescriptions.get(importPath);
143+
const text = description ? `${description} ${mdxText}` : mdxText;
144+
145+
if (text.length < 10) {
146+
skipped++;
147+
continue;
148+
}
149+
150+
const url = `?path=/docs/${entry.id}`;
151+
152+
const category = entry.title?.split(' / ')[0] || 'Docs';
153+
const title = entry.title?.split(' / ').pop() || entry.name;
154+
155+
const { errors } = await index.addCustomRecord({
156+
url,
157+
content: text,
158+
language: 'en',
159+
meta: {
160+
title: entry.title || title,
161+
category,
162+
},
163+
});
164+
165+
if (errors?.length) {
166+
console.warn(` Warning indexing ${mdxFile}:`, errors);
167+
} else {
168+
indexed++;
169+
}
170+
}
171+
172+
console.log(`Indexed ${indexed} docs, skipped ${skipped} files (no matching entry or too short)`);
173+
174+
// --- Write index ---
175+
176+
const pagefindDir = resolve(outDir, 'pagefind');
177+
const { errors: writeErrors } = await index.writeFiles({ outputPath: pagefindDir });
178+
179+
if (writeErrors?.length) {
180+
console.error('Errors writing Pagefind index:', writeErrors);
181+
process.exit(1);
182+
}
183+
184+
console.log(`Pagefind index written to ${relative(process.cwd(), pagefindDir)}/`);
185+
186+
await pagefind.close();
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { readFileSync, existsSync } from 'node:fs';
2+
import { dirname, resolve } from 'node:path';
3+
4+
// Finds the last `/** ... */ const Name =` in a component file and extracts
5+
// the description text, stripping JSDoc markers, @tags, tables, and boilerplate.
6+
export function extractComponentJSDoc(filePath: string): string | null {
7+
if (!existsSync(filePath)) return null;
8+
9+
const source = readFileSync(filePath, 'utf-8');
10+
11+
// Locate `*/ const Name =` then walk backwards to find the opening `/**`
12+
const constPattern = /\*\/\s*\n\s*(?:export\s+)?const\s+\w+\s*=/g;
13+
let constMatch: RegExpExecArray | null;
14+
let lastJSDoc: string | null = null;
15+
16+
while ((constMatch = constPattern.exec(source)) !== null) {
17+
const closeIndex = constMatch.index;
18+
const before = source.substring(0, closeIndex);
19+
const openIndex = before.lastIndexOf('/**');
20+
if (openIndex === -1) continue;
21+
lastJSDoc = source.substring(openIndex + 3, closeIndex);
22+
}
23+
if (!lastJSDoc) return null;
24+
25+
const lines = lastJSDoc.split('\n').map((line) => line.replace(/^\s*\*\s?/, '').trim());
26+
27+
const textParts: string[] = [];
28+
for (const line of lines) {
29+
if (line.startsWith('@')) continue;
30+
if (line.startsWith('__Note:__') || line.startsWith('__Note__:')) continue;
31+
if (line.startsWith('|') || line.startsWith('---')) continue;
32+
if (line.includes('UI5 Web Component!') || line.includes('Repository')) continue;
33+
if (line.startsWith('##')) continue;
34+
35+
const cleaned = line
36+
.replace(/`([^`]+)`/g, '$1')
37+
.replace(/\*{1,3}([^*]+)\*{1,3}/g, '$1')
38+
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
39+
.trim();
40+
41+
if (cleaned) textParts.push(cleaned);
42+
}
43+
44+
return textParts.length > 0 ? textParts.join(' ') : null;
45+
}
46+
47+
// Resolves MDX importPath to the component's index.tsx.
48+
// Handles docs/ subdirectories (e.g. components/AnalyticalTable/docs/AnalyticalTable.mdx).
49+
export function resolveComponentSource(mdxImportPath: string): string | null {
50+
const mdxPath = mdxImportPath.replace(/^\.\//, '');
51+
const mdxDir = dirname(mdxPath);
52+
53+
const componentDir = mdxDir.endsWith('/docs') ? dirname(mdxDir) : mdxDir;
54+
55+
for (const ext of ['index.tsx', 'index.ts']) {
56+
const candidate = resolve(componentDir, ext);
57+
if (existsSync(candidate)) return candidate;
58+
}
59+
60+
return null;
61+
}

0 commit comments

Comments
 (0)