diff --git a/.storybook/main.ts b/.storybook/main.ts index c19f279467..4ce4456890 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -1,4 +1,4 @@ -import {dirname} from 'path'; +import {dirname, join} from 'path'; import {fileURLToPath} from 'url'; import {VanillaExtractPlugin} from '@vanilla-extract/webpack-plugin'; import MiniCssExtractPlugin from 'mini-css-extract-plugin'; @@ -83,6 +83,7 @@ const config: StorybookConfig = { './font-size-addon/preset.ts', './theme-selector-addon/preset.ts', './platform-selector-addon/preset.ts', + './story-code-addon/preset.ts', ], framework: getAbsolutePath('@storybook/react-webpack5'), @@ -100,6 +101,17 @@ const config: StorybookConfig = { ], }; + // Add loader for story source code + config.module?.rules?.push({ + test: /-story\.tsx$/, + use: [ + { + loader: join(import.meta.dirname, './story-code-addon/story-source-loader.js'), + }, + ], + enforce: 'pre', + }); + // Define process.env variables for browser config.plugins?.push( /** workaround for: https://github.com/storybookjs/storybook/issues/22287 */ diff --git a/.storybook/story-code-addon/manager.tsx b/.storybook/story-code-addon/manager.tsx new file mode 100644 index 0000000000..38a5a1ea85 --- /dev/null +++ b/.storybook/story-code-addon/manager.tsx @@ -0,0 +1,315 @@ +import * as React from 'react'; +import {addons, types} from 'storybook/manager-api'; +import {AddonPanel} from 'storybook/internal/components'; + +import type {API} from 'storybook/manager-api'; + +const ADDON_ID = 'story-code'; +const PANEL_ID = `${ADDON_ID}/panel`; +const PANEL_TITLE = 'Code'; + +interface StoryCodePayload { + code: string; + focus?: [number, number]; +} + +interface StoryCodePanelProps { + api: API; +} + +// Simple syntax highlighting for TypeScript/React +const highlightCode = (code: string, focus?: [number, number]): React.ReactElement => { + const lines = code.split('\n'); + let focusAnchorAssigned = false; + + return ( + <> + {lines.map((line, lineIndex) => { + const lineNumber = lineIndex + 1; + const isFocused = + focus && focus.length === 2 && lineNumber >= focus[0] && lineNumber <= focus[1]; + // Process each line character by character to apply syntax highlighting + const tokens: Array<{text: string; color: string}> = []; + let currentToken = ''; + let currentColor = '#d4d4d4'; + let i = 0; + + while (i < line.length) { + const char = line[i]; + const remaining = line.slice(i); + + // String literals (single and double quotes) + if (char === "'" || char === '"' || char === '`') { + if (currentToken) { + tokens.push({text: currentToken, color: currentColor}); + currentToken = ''; + } + const quote = char; + let stringContent = quote; + i++; + while (i < line.length && line[i] !== quote) { + if (line[i] === '\\' && i + 1 < line.length) { + stringContent += line[i] + line[i + 1]; + i += 2; + } else { + stringContent += line[i]; + i++; + } + } + if (i < line.length) stringContent += line[i]; + tokens.push({text: stringContent, color: '#ce9178'}); + i++; + continue; + } + + // Comments + if (remaining.startsWith('//')) { + if (currentToken) { + tokens.push({text: currentToken, color: currentColor}); + } + tokens.push({text: remaining, color: '#6a9955'}); + break; + } + + // Multi-line comments + if (remaining.startsWith('/*')) { + if (currentToken) { + tokens.push({text: currentToken, color: currentColor}); + currentToken = ''; + } + let commentContent = '/*'; + i += 2; + while (i < line.length - 1 && !line.slice(i).startsWith('*/')) { + commentContent += line[i]; + i++; + } + if (line.slice(i).startsWith('*/')) { + commentContent += '*/'; + i += 2; + } + tokens.push({text: commentContent, color: '#6a9955'}); + continue; + } + + // JSX tags + if (char === '<' && /[A-Z]/.test(line[i + 1] || '')) { + if (currentToken) { + tokens.push({text: currentToken, color: currentColor}); + currentToken = ''; + } + currentColor = '#4ec9b0'; + currentToken = char; + i++; + while (i < line.length && /[A-Za-z.]/.test(line[i])) { + currentToken += line[i]; + i++; + } + tokens.push({text: currentToken, color: currentColor}); + currentToken = ''; + currentColor = '#d4d4d4'; + continue; + } + + // Keywords + if (/[a-zA-Z_]/.test(char)) { + currentToken += char; + i++; + while (i < line.length && /[a-zA-Z0-9_]/.test(line[i])) { + currentToken += line[i]; + i++; + } + + const keywords = [ + 'import', + 'export', + 'from', + 'const', + 'let', + 'var', + 'function', + 'return', + 'if', + 'else', + 'for', + 'while', + 'break', + 'continue', + 'class', + 'interface', + 'type', + 'extends', + 'implements', + 'new', + 'this', + 'super', + 'static', + 'public', + 'private', + 'protected', + 'async', + 'await', + 'try', + 'catch', + 'finally', + 'throw', + 'typeof', + 'instanceof', + 'void', + 'null', + 'undefined', + 'true', + 'false', + 'as', + 'default', + ]; + + if (keywords.includes(currentToken)) { + tokens.push({text: currentToken, color: '#569cd6'}); + } else if (/^[A-Z]/.test(currentToken)) { + // Types/Components start with uppercase + tokens.push({text: currentToken, color: '#4ec9b0'}); + } else { + tokens.push({text: currentToken, color: '#d4d4d4'}); + } + currentToken = ''; + continue; + } + + // Numbers + if (/[0-9]/.test(char)) { + currentToken += char; + i++; + while (i < line.length && /[0-9.]/.test(line[i])) { + currentToken += line[i]; + i++; + } + tokens.push({text: currentToken, color: '#b5cea8'}); + currentToken = ''; + continue; + } + + // Operators and punctuation + if (currentToken) { + tokens.push({text: currentToken, color: currentColor}); + currentToken = ''; + } + + const operatorColor = '{}[]()'.includes(char) ? '#ffd700' : '#d4d4d4'; + tokens.push({text: char, color: operatorColor}); + i++; + } + + if (currentToken) { + tokens.push({text: currentToken, color: currentColor}); + } + + const anchorId = isFocused && !focusAnchorAssigned ? 'story-code-focus-start' : undefined; + if (anchorId) focusAnchorAssigned = true; + + const focusedStyle = isFocused + ? { + backgroundColor: 'rgba(0, 102, 255, 0.2)', + display: 'block', + margin: '0 -32px', + padding: '0 32px', + } + : undefined; + + return ( +
+ {tokens.map((token, tokenIndex) => ( + + {token.text} + + ))} + {'\n'} +
+ ); + })} + + ); +}; + +const StoryCodePanel = ({api}: StoryCodePanelProps): React.ReactElement => { + const [storyCode, setStoryCode] = React.useState(null); + const [loading, setLoading] = React.useState(true); + const containerRef = React.useRef(null); + + React.useEffect(() => { + const channel = addons.getChannel(); + + const handleStoryCodeUpdate = (payload: string | StoryCodePayload) => { + const normalized: StoryCodePayload = typeof payload === 'string' ? {code: payload} : payload; + setStoryCode(normalized); + setLoading(false); + }; + + channel.on('story-code-update', handleStoryCodeUpdate); + + // Request code when story changes + const handleStoryChanged = () => { + setLoading(true); + channel.emit('story-code-request'); + }; + + api.on('storyChanged', handleStoryChanged); + + // Initial request + channel.emit('story-code-request'); + + return () => { + channel.off('story-code-update', handleStoryCodeUpdate); + api.off('storyChanged', handleStoryChanged); + }; + }, [api]); + + // Scroll to the focused range when story or focus changes + React.useEffect(() => { + if (!storyCode?.focus) return; + const container = containerRef.current; + if (!container) return; + + const anchor = container.querySelector('#story-code-focus-start'); + if (anchor && anchor instanceof HTMLElement) { + anchor.scrollIntoView({block: 'start', behavior: 'smooth'}); + } + }, [storyCode?.focus, storyCode?.code]); + + return ( +
+ {loading ? ( +
Loading story code...
+ ) : storyCode ? ( +
+                    {highlightCode(storyCode.code, storyCode.focus)}
+                
+ ) : ( +
No code available for this story
+ )} +
+ ); +}; + +addons.register(ADDON_ID, (api) => { + addons.add(PANEL_ID, { + type: types.PANEL, + title: PANEL_TITLE, + render: ({active}) => ( + + + + ), + }); +}); diff --git a/.storybook/story-code-addon/package.json b/.storybook/story-code-addon/package.json new file mode 100644 index 0000000000..9c9887939a --- /dev/null +++ b/.storybook/story-code-addon/package.json @@ -0,0 +1,5 @@ +{ + "name": "story-code-addon", + "version": "0.0.1", + "main": "preset.ts" +} diff --git a/.storybook/story-code-addon/preset.ts b/.storybook/story-code-addon/preset.ts new file mode 100644 index 0000000000..f50da8602d --- /dev/null +++ b/.storybook/story-code-addon/preset.ts @@ -0,0 +1,9 @@ +import {join} from 'path'; + +export const managerEntries = (entry: Array = []): Array => { + return [...entry, join(import.meta.dirname, './manager.tsx')]; +}; + +export const previewAnnotations = (entry: Array = []): Array => { + return [...entry, join(import.meta.dirname, './preview.tsx')]; +}; diff --git a/.storybook/story-code-addon/preview.tsx b/.storybook/story-code-addon/preview.tsx new file mode 100644 index 0000000000..d440f8b7d8 --- /dev/null +++ b/.storybook/story-code-addon/preview.tsx @@ -0,0 +1,79 @@ +import * as React from 'react'; +import {addons} from 'storybook/preview-api'; + +import type {Decorator} from '@storybook/react'; + +// Extend window type to include our global variable +declare global { + interface Window { + __STORY_SOURCE__?: Record; + } +} + +const StoryCodeDecorator: Decorator = (Story, context) => { + React.useEffect(() => { + const channel = addons.getChannel(); + + const handleCodeRequest = () => { + const fileName = context.parameters.fileName; + const focusParam = context.parameters.storyCode?.focus; + + const focus = + Array.isArray(focusParam) && + focusParam.length === 2 && + focusParam.every((n) => typeof n === 'number' && Number.isFinite(n) && n > 0) + ? ([focusParam[0], focusParam[1]] as [number, number]) + : undefined; + + // Try to get the source from our global variable + let storySource: string | undefined; + + // eslint-disable-next-line no-underscore-dangle + if (typeof window !== 'undefined' && window.__STORY_SOURCE__) { + // Try to find the source by matching the file path + // eslint-disable-next-line no-underscore-dangle + const sources = window.__STORY_SOURCE__; + + // Find a matching key in the sources object + const matchingKey = Object.keys(sources).find((key) => { + return ( + fileName && (key.includes(fileName) || fileName.includes(key.split('/').pop() || '')) + ); + }); + + if (matchingKey) { + storySource = sources[matchingKey]; + } + } + + // Also check if it's in parameters + if (!storySource && context.parameters.storySource) { + storySource = context.parameters.storySource; + } + + if (storySource) { + channel.emit('story-code-update', {code: storySource, focus}); + } else if (fileName) { + channel.emit('story-code-update', { + code: `// Story file: ${fileName}\n// No source code available. The source should be added to story parameters.\n\n// Available sources: ${Object.keys(window['__STORY_SOURCE__'] || {}).join(', ')}`, + focus, + }); + } else { + channel.emit('story-code-update', {code: '// Story source not available', focus}); + } + }; + + channel.on('story-code-request', handleCodeRequest); + + // Send code on mount + handleCodeRequest(); + + return () => { + channel.off('story-code-request', handleCodeRequest); + }; + }, [context.id, context.parameters]); + + return ; +}; + +export const decorators = [StoryCodeDecorator]; diff --git a/.storybook/story-code-addon/story-source-loader.js b/.storybook/story-code-addon/story-source-loader.js new file mode 100644 index 0000000000..32996e9081 --- /dev/null +++ b/.storybook/story-code-addon/story-source-loader.js @@ -0,0 +1,120 @@ +// Webpack loader to inject story source code into story parameters +const fs = require('fs'); +const ts = require('typescript'); + +module.exports = function (source) { + // Only process story files + if (this.resourcePath.includes('-story.tsx') || this.resourcePath.includes('.stories.tsx')) { + const filePath = this.resourcePath; + + // Read the original source file + const originalSource = fs.readFileSync(filePath, 'utf-8'); + + // Escape backticks and template literals in the source code + const escapedSource = originalSource + .replace(/\\/g, '\\\\') + .replace(/`/g, '\\`') + .replace(/\$\{/g, '\\${'); + + // Collect exported story ranges using TypeScript AST (robust, no extra deps) + const sourceFile = ts.createSourceFile( + filePath, + originalSource, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX + ); + + const focusMap = {}; + const fnLookup = new Map(); + + // Collect potential render functions by identifier to resolve Template.bind cases + sourceFile.forEachChild((node) => { + if (ts.isFunctionDeclaration(node) && node.name) { + fnLookup.set(node.name.text, node); + } + if (ts.isVariableStatement(node)) { + node.declarationList.declarations.forEach((decl) => { + if (ts.isIdentifier(decl.name) && decl.initializer) { + fnLookup.set(decl.name.text, decl.initializer); + } + }); + } + }); + + const isExported = (node) => + Array.isArray(node.modifiers) && + node.modifiers.some((m) => m.kind === ts.SyntaxKind.ExportKeyword); + + const addRange = (name, node) => { + if (!name) return; + + // Prefer inline render functions; for bind() keep the story declaration line + let targetNode = node; + + if (ts.isVariableDeclaration(node) && node.initializer) { + const init = node.initializer; + if (ts.isArrowFunction(init) || ts.isFunctionExpression(init)) { + targetNode = init; + } + } + + const start = sourceFile.getLineAndCharacterOfPosition(targetNode.getStart()); + const end = sourceFile.getLineAndCharacterOfPosition(targetNode.getEnd()); + focusMap[name] = [start.line + 1, end.line + 1]; + }; + + sourceFile.forEachChild((node) => { + if (ts.isFunctionDeclaration(node) && isExported(node) && node.name) { + addRange(node.name.text, node); + return; + } + + if (ts.isVariableStatement(node) && isExported(node)) { + node.declarationList.declarations.forEach((decl) => { + if (ts.isIdentifier(decl.name)) { + addRange(decl.name.text, decl); + } + }); + } + }); + + // Inject a global variable with the source code and line ranges + const injection = ` +// Injected by story-source-loader +if (typeof window !== 'undefined') { + window.__STORY_SOURCE__ = window.__STORY_SOURCE__ || {}; + window.__STORY_SOURCE__['${filePath}'] = \`${escapedSource}\`; + window.__STORY_SOURCE_LINES__ = window.__STORY_SOURCE_LINES__ || {}; + window.__STORY_SOURCE_LINES__['${filePath}'] = ${JSON.stringify(focusMap)}; +} +`; + + // After stories are defined, attach focus ranges into their parameters automatically + const parameterInjection = Object.entries(focusMap) + .map( + ([exportName, range]) => ` +try { + if (typeof ${exportName} !== 'undefined') { + ${exportName}.parameters = { + ...(typeof ${exportName}.parameters === 'object' ? ${exportName}.parameters : {}), + storyCode: { + ...(typeof ${exportName}.parameters?.storyCode === 'object' + ? ${exportName}.parameters.storyCode + : {}), + focus: [${range[0]}, ${range[1]}], + }, + }; + } +} catch (e) { + // Ignore errors when story export is not found +} +` + ) + .join('\n'); + + return injection + '\n' + source + '\n' + parameterInjection; + } + + return source; +};