From fed340a708bba252fafb7dda505d9fcda4d6ad54 Mon Sep 17 00:00:00 2001 From: Yossi Saadi Date: Sun, 21 Apr 2024 13:30:09 +0300 Subject: [PATCH 1/2] feat(withLiveEdit): apply decorators from within self CSF module of a story this should apply decorators which are declared in the same object of the story, not only global decorators, so we can make our stories cleaner --- .../LiveContent.module.scss} | 0 .../withLiveEdit/LiveContent/LiveContent.tsx | 21 +++++++++++++++++ .../LiveContent/LiveContent.types.ts | 8 +++++++ .../withLiveEdit/hooks/useApplyDecorators.ts | 18 +++++++++++++++ .../decorators/withLiveEdit/withLiveEdit.tsx | 23 ++++--------------- 5 files changed, 52 insertions(+), 18 deletions(-) rename packages/core/src/storybook/decorators/withLiveEdit/{withLiveEdit.module.scss => LiveContent/LiveContent.module.scss} (100%) create mode 100644 packages/core/src/storybook/decorators/withLiveEdit/LiveContent/LiveContent.tsx create mode 100644 packages/core/src/storybook/decorators/withLiveEdit/LiveContent/LiveContent.types.ts create mode 100644 packages/core/src/storybook/decorators/withLiveEdit/hooks/useApplyDecorators.ts diff --git a/packages/core/src/storybook/decorators/withLiveEdit/withLiveEdit.module.scss b/packages/core/src/storybook/decorators/withLiveEdit/LiveContent/LiveContent.module.scss similarity index 100% rename from packages/core/src/storybook/decorators/withLiveEdit/withLiveEdit.module.scss rename to packages/core/src/storybook/decorators/withLiveEdit/LiveContent/LiveContent.module.scss diff --git a/packages/core/src/storybook/decorators/withLiveEdit/LiveContent/LiveContent.tsx b/packages/core/src/storybook/decorators/withLiveEdit/LiveContent/LiveContent.tsx new file mode 100644 index 0000000000..d8ba70621d --- /dev/null +++ b/packages/core/src/storybook/decorators/withLiveEdit/LiveContent/LiveContent.tsx @@ -0,0 +1,21 @@ +import React from "react"; +import { LiveProvider } from "react-live"; +import LivePreview from "../../../components/live-preview/LivePreview"; +import useApplyDecorators from "../hooks/useApplyDecorators"; +import { LiveContentProps } from "./LiveContent.types"; +import styles from "./LiveContent.module.scss"; + +const LiveContent = ({ code, scope, decorators, context }: LiveContentProps) => { + const content: React.JSX.Element = ( + <> +
Modified Version
+ + + + + ); + + return <>{useApplyDecorators(decorators || [], content, context)}; +}; + +export default LiveContent; diff --git a/packages/core/src/storybook/decorators/withLiveEdit/LiveContent/LiveContent.types.ts b/packages/core/src/storybook/decorators/withLiveEdit/LiveContent/LiveContent.types.ts new file mode 100644 index 0000000000..e8752aebfe --- /dev/null +++ b/packages/core/src/storybook/decorators/withLiveEdit/LiveContent/LiveContent.types.ts @@ -0,0 +1,8 @@ +import { Decorator, StoryContext } from "@storybook/react"; + +export interface LiveContentProps { + code: string; + scope: Record; + decorators: Decorator[]; + context: StoryContext; +} diff --git a/packages/core/src/storybook/decorators/withLiveEdit/hooks/useApplyDecorators.ts b/packages/core/src/storybook/decorators/withLiveEdit/hooks/useApplyDecorators.ts new file mode 100644 index 0000000000..9bbe868091 --- /dev/null +++ b/packages/core/src/storybook/decorators/withLiveEdit/hooks/useApplyDecorators.ts @@ -0,0 +1,18 @@ +import React, { useMemo } from "react"; +import { StoryContext, Decorator } from "@storybook/react"; + +const useApplyDecorators = (decorators: Decorator[], component: React.ReactElement, context: StoryContext) => { + return useMemo(() => { + let decoratedComponent = () => component; + + // recursively apply decorators to the component + decorators.forEach(decorator => { + const currentComponent = decoratedComponent; + decoratedComponent = () => decorator(currentComponent, context); + }); + + return decoratedComponent(); + }, [decorators, component, context]); +}; + +export default useApplyDecorators; diff --git a/packages/core/src/storybook/decorators/withLiveEdit/withLiveEdit.tsx b/packages/core/src/storybook/decorators/withLiveEdit/withLiveEdit.tsx index c784c33273..bb8bb24330 100644 --- a/packages/core/src/storybook/decorators/withLiveEdit/withLiveEdit.tsx +++ b/packages/core/src/storybook/decorators/withLiveEdit/withLiveEdit.tsx @@ -1,4 +1,4 @@ -import { Decorator, Parameters as StorybookParameters } from "@storybook/react"; +import { Decorator, StoryContext } from "@storybook/react"; import { useMemo, useRef, useState } from "react"; import { vscodeDark } from "@uiw/codemirror-theme-vscode"; import { langs } from "@uiw/codemirror-extensions-langs"; @@ -6,21 +6,12 @@ import * as VibeComponents from "../../../components"; import * as VibeComponentsNext from "../../../next"; import * as VibeIcons from "../../../components/Icon/Icons"; import { createPortal } from "react-dom"; -import { LiveProvider } from "react-live"; -import LivePreview from "../../components/live-preview/LivePreview"; -import styles from "./withLiveEdit.module.scss"; import LiveEditor from "../../components/live-editor/LiveEditor"; import { formatCode } from "./prettier-utils"; +import LiveContent from "./LiveContent/LiveContent"; const globalScope = { ...VibeComponents, VibeIcons, VibeNext: VibeComponentsNext }; -type Parameters = StorybookParameters & { - liveEdit?: { - scope?: Record; - isEnabled?: boolean; - }; -}; - function getInitialCodeValue(code: string, shouldPrintError: boolean): string { try { return formatCode(code); @@ -32,7 +23,8 @@ function getInitialCodeValue(code: string, shouldPrintError: boolean): string { } } -const withLiveEdit: Decorator = (Story, { id, parameters, viewMode }: Parameters) => { +const withLiveEdit: Decorator = (Story, context: StoryContext) => { + const { id, parameters, viewMode, moduleExport } = context; const scope = { ...globalScope, ...parameters.docs?.liveEdit?.scope }; const canvasEditorContainer = useMemo(() => document.getElementById(id), [id]); const shouldAllowLiveEdit = viewMode === "docs" && parameters.docs?.liveEdit?.isEnabled && !!canvasEditorContainer; @@ -53,12 +45,7 @@ const withLiveEdit: Decorator = (Story, { id, parameters, viewMode }: Parameters return ( <> {dirty ? ( - <> -
Modified Version
- - - - + ) : ( )} From 41eb4b948dd97916b289a55885a1157d255d1c28 Mon Sep 17 00:00:00 2001 From: Yossi Saadi Date: Sun, 21 Apr 2024 13:55:20 +0300 Subject: [PATCH 2/2] feat(withLiveEdit): parse render attribute with ast instead of with regex for variety of cases --- packages/core/package.json | 4 + .../withLiveEdit/utils/parse-csf-utils.ts | 90 +++++++++++++++++++ .../{ => utils}/prettier-utils.ts | 0 .../decorators/withLiveEdit/withLiveEdit.tsx | 29 +++--- yarn.lock | 19 +++- 5 files changed, 122 insertions(+), 20 deletions(-) create mode 100644 packages/core/src/storybook/decorators/withLiveEdit/utils/parse-csf-utils.ts rename packages/core/src/storybook/decorators/withLiveEdit/{ => utils}/prettier-utils.ts (100%) diff --git a/packages/core/package.json b/packages/core/package.json index dd9be09d59..b45c445c71 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -126,6 +126,7 @@ "devDependencies": { "@babel/core": "^7.23.2", "@babel/eslint-parser": "^7.16.5", + "@babel/parser": "^7.24.4", "@babel/plugin-proposal-class-properties": "^7.16.5", "@babel/plugin-proposal-private-methods": "^7.18.6", "@babel/plugin-proposal-private-property-in-object": "^7.21.11", @@ -135,6 +136,8 @@ "@babel/preset-env": "^7.16.5", "@babel/preset-react": "^7.16.5", "@babel/preset-typescript": "^7.23.3", + "@babel/standalone": "^7.24.4", + "@babel/types": "^7.24.0", "@hot-loader/react-dom": "^16.13.0", "@jest/transform": "^26.6.2", "@mdx-js/loader": "^2.0.0-rc.2", @@ -170,6 +173,7 @@ "@testing-library/react-hooks": "^7.0.2", "@testing-library/user-event": "^13.5.0", "@types/autosize": "^4.0.1", + "@types/babel__standalone": "^7.1.7", "@types/body-scroll-lock": "^3.1.0", "@types/lodash": "^4.14.184", "@types/lodash-es": "^4.17.6", diff --git a/packages/core/src/storybook/decorators/withLiveEdit/utils/parse-csf-utils.ts b/packages/core/src/storybook/decorators/withLiveEdit/utils/parse-csf-utils.ts new file mode 100644 index 0000000000..09bd435b12 --- /dev/null +++ b/packages/core/src/storybook/decorators/withLiveEdit/utils/parse-csf-utils.ts @@ -0,0 +1,90 @@ +import { transformFromAst } from "@babel/standalone"; +import { parse } from "@babel/parser"; +import { + File, + Program, + Expression, + Statement, + ObjectProperty, + FunctionExpression, + ArrowFunctionExpression, + isObjectExpression, + isBlockStatement, + isExpressionStatement, + isObjectProperty, + isIdentifier, + isArrowFunctionExpression, + isFunctionExpression +} from "@babel/types"; + +const parseCsfToAst = (csfString: string) => { + return parse(csfString, { + plugins: ["typescript", "jsx"], + sourceType: "module" + }); +}; + +function findRenderProperty(body: Statement[]): ObjectProperty { + return body + .flatMap(node => + isExpressionStatement(node) && isObjectExpression(node.expression) ? node.expression.properties : [] + ) + .find(prop => isObjectProperty(prop) && isIdentifier(prop.key) && prop.key.name === "render") as ObjectProperty; +} + +function prepareTransformTarget(renderProperty: ObjectProperty): Program { + const { value } = renderProperty; + const isFunction = isFunctionExpression(value) || isArrowFunctionExpression(value); + if (!isFunction) { + // e.g. render: something + const expression = value as Expression; + return { + type: "Program", + body: [{ type: "ExpressionStatement", expression }], + directives: [], + sourceType: "module" + }; + } + + // e.g. render: () => {...}, render: () => (...), render() + const { body } = value as FunctionExpression | ArrowFunctionExpression; + + return { + type: "Program", + body: [ + { + type: "ExpressionStatement", + expression: isBlockStatement(body) ? value : body + } + ], + directives: [], + sourceType: "module" + }; +} + +function transformAstToCode(ast: File): string { + // docs says transformFromAst is void, but it is not because of a breaking change + const { code } = transformFromAst(ast, null, { presets: [] }) as unknown as { code: string }; + return code; +} + +export function extractRenderAttributeFromCsf(csfString: string) { + const originalAst = parseCsfToAst(csfString); + + const renderProperty = findRenderProperty(originalAst.program.body); + if (!renderProperty) { + return null; + } + + const transformTarget = prepareTransformTarget(renderProperty); + if (!transformTarget) { + return null; + } + + const transformedAst: File = { + type: "File", + program: transformTarget + }; + + return transformAstToCode(transformedAst); +} diff --git a/packages/core/src/storybook/decorators/withLiveEdit/prettier-utils.ts b/packages/core/src/storybook/decorators/withLiveEdit/utils/prettier-utils.ts similarity index 100% rename from packages/core/src/storybook/decorators/withLiveEdit/prettier-utils.ts rename to packages/core/src/storybook/decorators/withLiveEdit/utils/prettier-utils.ts diff --git a/packages/core/src/storybook/decorators/withLiveEdit/withLiveEdit.tsx b/packages/core/src/storybook/decorators/withLiveEdit/withLiveEdit.tsx index bb8bb24330..73ddf3e7af 100644 --- a/packages/core/src/storybook/decorators/withLiveEdit/withLiveEdit.tsx +++ b/packages/core/src/storybook/decorators/withLiveEdit/withLiveEdit.tsx @@ -7,19 +7,22 @@ import * as VibeComponentsNext from "../../../next"; import * as VibeIcons from "../../../components/Icon/Icons"; import { createPortal } from "react-dom"; import LiveEditor from "../../components/live-editor/LiveEditor"; -import { formatCode } from "./prettier-utils"; +import { formatCode } from "./utils/prettier-utils"; import LiveContent from "./LiveContent/LiveContent"; +import { extractRenderAttributeFromCsf } from "./utils/parse-csf-utils"; const globalScope = { ...VibeComponents, VibeIcons, VibeNext: VibeComponentsNext }; -function getInitialCodeValue(code: string, shouldPrintError: boolean): string { +function getInitialCodeValue(source: string, shouldPrintError: boolean): string { try { + // need to wrap with parentheses to avoid syntax errors + const code = extractRenderAttributeFromCsf(`(${source})`); return formatCode(code); } catch (e) { if (shouldPrintError) { console.error(e); } - return code; + return source; } } @@ -29,8 +32,10 @@ const withLiveEdit: Decorator = (Story, context: StoryContext) => { const canvasEditorContainer = useMemo(() => document.getElementById(id), [id]); const shouldAllowLiveEdit = viewMode === "docs" && parameters.docs?.liveEdit?.isEnabled && !!canvasEditorContainer; - const originalCode = useRef(extractCodeFromSource(parameters.docs.source?.originalSource) || ""); - const [code, setCode] = useState(getInitialCodeValue(originalCode.current, shouldAllowLiveEdit)); + const originalCode = useRef( + getInitialCodeValue(parameters.docs.source?.originalSource || "", shouldAllowLiveEdit) + ); + const [code, setCode] = useState(originalCode.current); const [dirty, setDirty] = useState(false); const handleChange = (newVal: string) => { @@ -70,18 +75,4 @@ const withLiveEdit: Decorator = (Story, context: StoryContext) => { ); }; -function extractCodeFromSource(csfSource: string): string { - // capture "render:" from the string - if (!csfSource) { - return ""; - } - const match = csfSource.match(/render:\s*(?:\(\)\s*=>\s*)?([\s\S]*?)(?=\s*,\s*[\w]+:\s*|}$)/); - - if (!match?.[1]) { - return ""; - } - - return match[1].trim(); -} - export default withLiveEdit; diff --git a/yarn.lock b/yarn.lock index 43912b3550..b45d2fac21 100644 --- a/yarn.lock +++ b/yarn.lock @@ -284,6 +284,11 @@ resolved "https://registry.npmjs.org/@babel/parser/-/parser-7.24.1.tgz#1e416d3627393fab1cb5b0f2f1796a100ae9133a" integrity sha512-Zo9c7N3xdOIQrNip7Lc9wvRPzlRtovHVE4lkz8WEDr7uYh/GMQhSiIgFxGIArRHYdJE5kxtZjAf8rT0xhdLCzg== +"@babel/parser@^7.24.4": + version "7.24.4" + resolved "https://registry.npmjs.org/@babel/parser/-/parser-7.24.4.tgz#234487a110d89ad5a3ed4a8a566c36b9453e8c88" + integrity sha512-zTvEBcghmeBma9QIGunWevvBAp4/Qu9Bdq+2k0Ot4fVMD6v3dsC9WOcRSKk7tRRyBM/53yKMJko9xOatGQAwSg== + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@^7.24.1": version "7.24.1" resolved "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.24.1.tgz#b645d9ba8c2bc5b7af50f0fe949f9edbeb07c8cf" @@ -1102,6 +1107,11 @@ dependencies: regenerator-runtime "^0.14.0" +"@babel/standalone@^7.24.4": + version "7.24.4" + resolved "https://registry.npmjs.org/@babel/standalone/-/standalone-7.24.4.tgz#9461220fd641a92fff4be19b34fdb9d18e80d37d" + integrity sha512-V4uqWeedadiuiCx5P5OHYJZ1PehdMpcBccNCEptKFGPiZIY3FI5f2ClxUl4r5wZ5U+ohcQ+4KW6jX2K6xXzq4Q== + "@babel/template@^7.22.15", "@babel/template@^7.24.0", "@babel/template@^7.3.3": version "7.24.0" resolved "https://registry.npmjs.org/@babel/template/-/template-7.24.0.tgz#c6a524aa93a4a05d66aaf31654258fae69d87d50" @@ -4501,7 +4511,7 @@ resolved "https://registry.npmjs.org/@types/autosize/-/autosize-4.0.3.tgz#77548c35bca4cc6281593228cde9a50112e1f94c" integrity sha512-o0ZyU3ePp3+KRbhHsY4ogjc+ZQWgVN5h6j8BHW5RII4cFKi6PEKK9QPAcphJVkD0dGpyFnD3VRR0WMvHVjCv9w== -"@types/babel__core@^7.0.0", "@types/babel__core@^7.1.14", "@types/babel__core@^7.1.7", "@types/babel__core@^7.18.0": +"@types/babel__core@^7.0.0", "@types/babel__core@^7.1.0", "@types/babel__core@^7.1.14", "@types/babel__core@^7.1.7", "@types/babel__core@^7.18.0": version "7.20.5" resolved "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz#3df15f27ba85319caa07ba08d0721889bb39c017" integrity sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA== @@ -4519,6 +4529,13 @@ dependencies: "@babel/types" "^7.0.0" +"@types/babel__standalone@^7.1.7": + version "7.1.7" + resolved "https://registry.npmjs.org/@types/babel__standalone/-/babel__standalone-7.1.7.tgz#8ab09548a24f54015e7d84a55486148180bc4ace" + integrity sha512-4RUJX9nWrP/emaZDzxo/+RYW8zzLJTXWJyp2k78HufG459HCz754hhmSymt3VFOU6/Wy+IZqfPvToHfLuGOr7w== + dependencies: + "@types/babel__core" "^7.1.0" + "@types/babel__template@*": version "7.4.4" resolved "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz#5672513701c1b2199bc6dad636a9d7491586766f"