diff --git a/.storybook/components/ContentSearch.tsx b/.storybook/components/ContentSearch.tsx new file mode 100644 index 00000000000..bb7b313d7d9 --- /dev/null +++ b/.storybook/components/ContentSearch.tsx @@ -0,0 +1,45 @@ +import { SearchIcon } from '@storybook/icons'; +import * as React from 'react'; +import { IconButton } from 'storybook/internal/components'; +import { addons, types } from 'storybook/manager-api'; + +const ADDON_ID = 'content-search'; +const TOOL_ID = `${ADDON_ID}/toolbar`; +const isMac = navigator.platform.toUpperCase().includes('MAC'); +const shortcut = isMac ? '⌘⇧F' : 'Ctrl+Shift+F'; + +function SearchButton() { + const openSearch = React.useCallback(() => { + const dialog = document.getElementById('pagefind-search-container'); + if (dialog && dialog instanceof HTMLDialogElement && !dialog.open) { + dialog.showModal(); + setTimeout(() => dialog.querySelector('.pagefind-ui__search-input')?.focus(), 50); + } + }, []); + + React.useEffect(() => { + const handleKeydown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'f') { + e.preventDefault(); + openSearch(); + } + }; + document.addEventListener('keydown', handleKeydown); + return () => document.removeEventListener('keydown', handleKeydown); + }, [openSearch]); + + return ( + + + Search Docs (Beta) + + ); +} + +addons.register(ADDON_ID, () => { + addons.add(TOOL_ID, { + type: types.TOOLEXTRA, + title: 'Search documentation content', + render: () => , + }); +}); diff --git a/.storybook/content-search/content-search.css b/.storybook/content-search/content-search.css new file mode 100644 index 00000000000..0439c0d74ce --- /dev/null +++ b/.storybook/content-search/content-search.css @@ -0,0 +1,17 @@ +#pagefind-search-container { + border: none; + padding: 1rem; + max-width: 80vw; + max-height: 80vh; + width: 100%; + overflow-y: auto; + background: var(--sapBackgroundColor); + border-radius: var(--sapElement_BorderCornerRadius); + box-shadow: var(--sapContent_Shadow3); +} +#pagefind-search-container::backdrop { + background: rgba(0, 0, 0, 0.5); +} +#pagefind-search-container .pagefind-ui__result-link { + color: var(--sapLinkColor); +} diff --git a/.storybook/content-search/content-search.js b/.storybook/content-search/content-search.js new file mode 100644 index 00000000000..18afba7be95 --- /dev/null +++ b/.storybook/content-search/content-search.js @@ -0,0 +1,20 @@ +/* global PagefindUI */ + +const container = document.getElementById('pagefind-search-container'); + +if (container) { + try { + new PagefindUI({ + element: '#pagefind-ui', + showSubResults: true, + showImages: false, + showEmptyFilters: false, + }); + } catch { + // PagefindUI may not be available in dev mode (index only exists after build) + } + + container.addEventListener('click', (e) => { + if (e.target === container) container.close(); + }); +} diff --git a/.storybook/main.ts b/.storybook/main.ts index 50944e0b048..b4fb21b8417 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -81,7 +81,7 @@ const config: StorybookConfig = { shouldRemoveUndefinedFromOptional: true, }, }, - staticDirs: isDevMode ? ['images-dev'] : ['images'], + staticDirs: isDevMode ? ['images-dev', 'content-search'] : ['images', 'content-search'], viteFinal: (viteConfig) => mergeConfig(viteConfig, { build: { diff --git a/.storybook/manager-head.html b/.storybook/manager-head.html index 1eba338fa92..cdcc9271204 100644 --- a/.storybook/manager-head.html +++ b/.storybook/manager-head.html @@ -52,3 +52,13 @@ } + + + + + + +
+
+ + diff --git a/.storybook/manager.tsx b/.storybook/manager.tsx index 16ceeee0ce6..6b716c8a722 100644 --- a/.storybook/manager.tsx +++ b/.storybook/manager.tsx @@ -5,6 +5,7 @@ import { addons } from 'storybook/manager-api'; import { Badge } from './components/Badge.js'; import { Fiori4ReactTheme } from './theme.js'; import './components/VersionSwitch.js'; +import './components/ContentSearch.js'; const customTags = new Set(['package:@ui5/webcomponents-react-charts', 'custom']); diff --git a/package.json b/package.json index ac6f779b439..b398a0f3c71 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "start:storybook": "storybook dev -p 6006", "setup": "lerna run build:i18n && lerna run build:css && lerna run build:css-bundle && lerna run build:version-info && rimraf node_modules/@types/mocha", "build": "yarn setup && tsc --build tsconfig.build.json && lerna run build:client && lerna run build:wrapper", - "build:storybook": "yarn build && yarn create-cypress-commands-docs && storybook build -o .out", + "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", "build:storybook-sitemap": "node ./scripts/create-storybook-sitemap.js --directory .out", "test:prepare": "rimraf temp && lerna run build", "test:open": "CYPRESS_COVERAGE=false cypress open --component --browser chrome", @@ -98,6 +98,7 @@ "lint-staged": "16.4.0", "monocart-reporter": "2.10.0", "npm-run-all2": "8.0.4", + "pagefind": "1.5.2", "postcss": "8.5.9", "postcss-cli": "11.0.1", "postcss-import": "16.1.1", diff --git a/scripts/storybook-search/build-index.ts b/scripts/storybook-search/build-index.ts new file mode 100644 index 00000000000..2f168865fa3 --- /dev/null +++ b/scripts/storybook-search/build-index.ts @@ -0,0 +1,186 @@ +import { readFileSync, existsSync } from 'node:fs'; +import { resolve, relative } from 'node:path'; +import { parseArgs } from 'node:util'; +import { glob } from 'glob'; +import * as pagefind from 'pagefind'; +import { extractComponentJSDoc, resolveComponentSource } from './extract-comp-description.ts'; + +interface StoryIndexEntry { + id: string; + type: string; + name: string; + title?: string; + importPath?: string; + tags?: string[]; +} + +// --- CLI args --- + +const { values } = parseArgs({ + options: { + directory: { type: 'string', short: 'd' }, + }, +}); + +if (typeof values.directory !== 'string') { + throw new Error('Expected --directory to be a string (e.g. --directory .out)'); +} + +const outDir = resolve(process.cwd(), values.directory); +const indexJsonPath = resolve(outDir, 'index.json'); + +if (!existsSync(indexJsonPath)) { + throw new Error(`index.json not found at ${indexJsonPath}. Did you run "storybook build" first?`); +} + +// --- Read Storybook index.json --- + +const storiesJson = JSON.parse(readFileSync(indexJsonPath, 'utf-8')); +const entries: StoryIndexEntry[] = Object.values(storiesJson.entries); + +const importPathToEntry = new Map(); +for (const entry of entries) { + if (entry.type === 'docs' && entry.importPath?.endsWith('.mdx')) { + importPathToEntry.set(entry.importPath, entry); + } +} + +console.log(`Found ${importPathToEntry.size} docs entries in index.json`); + +const mdxFiles = await glob('**/*.mdx', { + cwd: process.cwd(), + ignore: ['node_modules/**', '.out/**', '**/node_modules/**'], +}); + +console.log(`Found ${mdxFiles.length} MDX files on disk`); + +function extractTextFromMdx(source: string): string { + const lines = source.split('\n'); + const textParts: string[] = []; + let inCodeBlock = false; + + for (const line of lines) { + if (line.trimStart().startsWith('```')) { + inCodeBlock = !inCodeBlock; + continue; + } + + if (inCodeBlock) continue; + + if (/^\s*import\s/.test(line)) continue; + if (/^\s*export\s/.test(line)) continue; + + // Skip JSX-only lines (tags with no text content) + if (/^\s*<[A-Z][\w.]*[\s/>]/.test(line) && !/>([^<]+)]+>/g, '') + .replace(/\{`([^`]*)`\}/g, '$1') + .replace(/\{['"]([^'"]*)['"]\}/g, '$1') + .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') + .replace(/`([^`]+)`/g, '$1') + .replace(/\*{1,3}([^*]+)\*{1,3}/g, '$1') + .replace(/[<>]/g, '') + .trim(); + + if (cleaned.length > 0) { + textParts.push(cleaned); + } + } + + return textParts.join(' '); +} + +// --- Extract component descriptions from JSDoc --- + +const componentDescriptions = new Map(); +for (const entry of entries) { + if (entry.type !== 'docs' || !entry.importPath?.endsWith('.mdx')) continue; + if (!entry.tags?.includes('attached-mdx')) continue; + + const sourceFile = resolveComponentSource(entry.importPath); + if (!sourceFile) continue; + + const description = extractComponentJSDoc(sourceFile); + if (description) { + componentDescriptions.set(entry.importPath, description); + } +} + +console.log(`Extracted ${componentDescriptions.size} component descriptions from JSDoc`); + +// --- Build Pagefind index --- + +const { index } = await pagefind.createIndex(); +if (!index) { + throw new Error('Failed to create Pagefind index'); +} + +let indexed = 0; +let skipped = 0; + +for (const mdxFile of mdxFiles) { + const importPath = './' + mdxFile; + const entry = importPathToEntry.get(importPath); + + if (!entry) { + skipped++; + continue; + } + + const source = readFileSync(mdxFile, 'utf-8'); + const mdxText = extractTextFromMdx(source); + + const description = componentDescriptions.get(importPath); + const text = description ? `${description} ${mdxText}` : mdxText; + + if (text.length < 10) { + skipped++; + continue; + } + + const url = `?path=/docs/${entry.id}`; + + const category = entry.title?.split(' / ')[0] || 'Docs'; + const title = entry.title?.split(' / ').pop() || entry.name; + + const { errors } = await index.addCustomRecord({ + url, + content: text, + language: 'en', + meta: { + title: entry.title || title, + category, + }, + }); + + if (errors?.length) { + console.warn(` Warning indexing ${mdxFile}:`, errors); + } else { + indexed++; + } +} + +console.log(`Indexed ${indexed} docs, skipped ${skipped} files (no matching entry or too short)`); + +// --- Write index --- + +const pagefindDir = resolve(outDir, 'pagefind'); +const { errors: writeErrors } = await index.writeFiles({ outputPath: pagefindDir }); + +if (writeErrors?.length) { + console.error('Errors writing Pagefind index:', writeErrors); + process.exit(1); +} + +console.log(`Pagefind index written to ${relative(process.cwd(), pagefindDir)}/`); + +await pagefind.close(); diff --git a/scripts/storybook-search/extract-comp-description.ts b/scripts/storybook-search/extract-comp-description.ts new file mode 100644 index 00000000000..370673cc1ae --- /dev/null +++ b/scripts/storybook-search/extract-comp-description.ts @@ -0,0 +1,61 @@ +import { readFileSync, existsSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; + +// Finds the last `/** ... */ const Name =` in a component file and extracts +// the description text, stripping JSDoc markers, @tags, tables, and boilerplate. +export function extractComponentJSDoc(filePath: string): string | null { + if (!existsSync(filePath)) return null; + + const source = readFileSync(filePath, 'utf-8'); + + // Locate `*/ const Name =` then walk backwards to find the opening `/**` + const constPattern = /\*\/\s*\n\s*(?:export\s+)?const\s+\w+\s*=/g; + let constMatch: RegExpExecArray | null; + let lastJSDoc: string | null = null; + + while ((constMatch = constPattern.exec(source)) !== null) { + const closeIndex = constMatch.index; + const before = source.substring(0, closeIndex); + const openIndex = before.lastIndexOf('/**'); + if (openIndex === -1) continue; + lastJSDoc = source.substring(openIndex + 3, closeIndex); + } + if (!lastJSDoc) return null; + + const lines = lastJSDoc.split('\n').map((line) => line.replace(/^\s*\*\s?/, '').trim()); + + const textParts: string[] = []; + for (const line of lines) { + if (line.startsWith('@')) continue; + if (line.startsWith('__Note:__') || line.startsWith('__Note__:')) continue; + if (line.startsWith('|') || line.startsWith('---')) continue; + if (line.includes('UI5 Web Component!') || line.includes('Repository')) continue; + if (line.startsWith('##')) continue; + + const cleaned = line + .replace(/`([^`]+)`/g, '$1') + .replace(/\*{1,3}([^*]+)\*{1,3}/g, '$1') + .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') + .trim(); + + if (cleaned) textParts.push(cleaned); + } + + return textParts.length > 0 ? textParts.join(' ') : null; +} + +// Resolves MDX importPath to the component's index.tsx. +// Handles docs/ subdirectories (e.g. components/AnalyticalTable/docs/AnalyticalTable.mdx). +export function resolveComponentSource(mdxImportPath: string): string | null { + const mdxPath = mdxImportPath.replace(/^\.\//, ''); + const mdxDir = dirname(mdxPath); + + const componentDir = mdxDir.endsWith('/docs') ? dirname(mdxDir) : mdxDir; + + for (const ext of ['index.tsx', 'index.ts']) { + const candidate = resolve(componentDir, ext); + if (existsSync(candidate)) return candidate; + } + + return null; +} diff --git a/yarn.lock b/yarn.lock index 4e7c2fcec3d..3a1bfe640c9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3344,6 +3344,55 @@ __metadata: languageName: node linkType: hard +"@pagefind/darwin-arm64@npm:1.5.2": + version: 1.5.2 + resolution: "@pagefind/darwin-arm64@npm:1.5.2" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@pagefind/darwin-x64@npm:1.5.2": + version: 1.5.2 + resolution: "@pagefind/darwin-x64@npm:1.5.2" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@pagefind/freebsd-x64@npm:1.5.2": + version: 1.5.2 + resolution: "@pagefind/freebsd-x64@npm:1.5.2" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"@pagefind/linux-arm64@npm:1.5.2": + version: 1.5.2 + resolution: "@pagefind/linux-arm64@npm:1.5.2" + conditions: os=linux & cpu=arm64 + languageName: node + linkType: hard + +"@pagefind/linux-x64@npm:1.5.2": + version: 1.5.2 + resolution: "@pagefind/linux-x64@npm:1.5.2" + conditions: os=linux & cpu=x64 + languageName: node + linkType: hard + +"@pagefind/windows-arm64@npm:1.5.2": + version: 1.5.2 + resolution: "@pagefind/windows-arm64@npm:1.5.2" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@pagefind/windows-x64@npm:1.5.2": + version: 1.5.2 + resolution: "@pagefind/windows-x64@npm:1.5.2" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@pkgr/core@npm:^0.2.9": version: 0.2.9 resolution: "@pkgr/core@npm:0.2.9" @@ -17290,6 +17339,38 @@ __metadata: languageName: node linkType: hard +"pagefind@npm:1.5.2": + version: 1.5.2 + resolution: "pagefind@npm:1.5.2" + dependencies: + "@pagefind/darwin-arm64": "npm:1.5.2" + "@pagefind/darwin-x64": "npm:1.5.2" + "@pagefind/freebsd-x64": "npm:1.5.2" + "@pagefind/linux-arm64": "npm:1.5.2" + "@pagefind/linux-x64": "npm:1.5.2" + "@pagefind/windows-arm64": "npm:1.5.2" + "@pagefind/windows-x64": "npm:1.5.2" + dependenciesMeta: + "@pagefind/darwin-arm64": + optional: true + "@pagefind/darwin-x64": + optional: true + "@pagefind/freebsd-x64": + optional: true + "@pagefind/linux-arm64": + optional: true + "@pagefind/linux-x64": + optional: true + "@pagefind/windows-arm64": + optional: true + "@pagefind/windows-x64": + optional: true + bin: + pagefind: lib/runner/bin.cjs + checksum: 10c0/bcc4f34f473bfeb09dc4cb464a3547bf958e11ee4dee4144ec7a364bd74b9f7c21f85be71e388b66d30923a9a453dcaed2f6b425f3e67631cadc9650ba741aab + languageName: node + linkType: hard + "parent-module@npm:^1.0.0": version: 1.0.1 resolution: "parent-module@npm:1.0.1" @@ -21472,6 +21553,7 @@ __metadata: lint-staged: "npm:16.4.0" monocart-reporter: "npm:2.10.0" npm-run-all2: "npm:8.0.4" + pagefind: "npm:1.5.2" postcss: "npm:8.5.9" postcss-cli: "npm:11.0.1" postcss-import: "npm:16.1.1"