Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix source snippet generation #13

Merged
merged 15 commits into from
Nov 12, 2024
Merged
2 changes: 1 addition & 1 deletion .github/workflows/solid-vite.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,4 @@ jobs:
run: yarn npm publish
working-directory: packages/frameworks/solid-vite
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
YARN_NPM_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
2 changes: 1 addition & 1 deletion .github/workflows/solid.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,4 @@ jobs:
run: yarn npm publish
working-directory: packages/renderers/solid
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
YARN_NPM_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,6 @@ node_modules
# build
dist
bench

# vite
vite.config.ts.timestamp-*
3 changes: 2 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ It has `yarn storybook` for testing Storybook and `yarn dev` for testing the app
For testing with external projects that use Yarn, the framework and renderer can be linked locally.

**Note:** The default Yarn Plug n' Play installs WILL NOT work when testing.
This is because Yarn PnP will use virtual paths for dependencies of linked dependencies. The framework does not resolve these virtual paths, so your test app will break.
This is because Yarn PnP will use virtual paths for dependencies of linked dependencies.
The framework does not resolve these virtual paths, so your test app will break.
This can be fixed by specifying the node linker to be "node-modules".

### Example External Testing App
Expand Down
24 changes: 11 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,30 +2,28 @@

This is a framework to allow using [Storybook](https://storybook.js.org/) with [SolidJS](https://www.solidjs.com/).

| Feature | State |
| --------------------------------------------------- | :---: |
| `JS` and `TS` integration with Storybook cli | ✅ |
| Fine grained updates in storybook controls | ✅ |
| Compatible with `@storybook/addon-interactions` | ✅ |
| Compatible with `@storybook/test` | ✅ |
| Automatic story actions | ⏳ |
| Full props table with description in docs view mode | ⏳ |
| Code snippets generation in docs view mode | ⏳ |
| `SolidJS` docs in the official Storybook website | ⏳ |
## Features

- [x] `JS` and `TS` integration with Storybook CLI
- [x] Fine grained updates in storybook controls
- [x] Compatible with `@storybook/addon-interactions`
- [x] Compatible with `@storybook/test`
- [x] Code snippets generation in docs view mode
- [ ] Automatic story actions
- [ ] Full props table with description in docs view mode
- [ ] `SolidJS` docs in the official Storybook website

## Notes about pending features ⏳

- **Automatic story actions**: Feature under research. For now actions can be implemented manually by using the `@storybook/addon-actions` API.

- **Full props table with description in docs view mode**: Feature under research. For now, props are rendered partially in the docs view table with a blank description.

- **Code snippets generation in docs view mode**: Feature under research. Because `solid` components lack a virtual dom, a common `jsx-parser` doesn't work for generating a code snippet from the rendered story. For now, it outputs the original story source code. To output a more complex code implementation, you can use the `render` key inside a `csf` story definition.

- **`SolidJS` docs in the official Storybook website**: It's still pending to add documentation about Storybook stories definitions using SolidJS.

## Setup

In an existing Solid project, run `npx storybook@next init` (Storybook 8+ is required)
In an existing Solid project, run `npx storybook@latest init` (Storybook 8+ is required)

See the [Storybook Docs](https://storybook.js.org/docs?renderer=solid) for the best documentation on getting started with Storybook.

Expand Down
2 changes: 1 addition & 1 deletion example/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,6 @@
"vite-plugin-solid": "^2.8.2"
},
"dependencies": {
"solid-js": "^1.9.2"
"solid-js": "^1.9.3"
}
}
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"@fal-works/esbuild-plugin-global-externals": "^2.1.2",
"@types/fs-extra": "^11.0.4",
"@types/node": "^18.0.0",
"esbuild": "^0.24.0",
"esbuild-plugin-alias": "^0.2.1",
"esbuild-plugin-solid": "^0.6.0",
"eslint": "^9.13.0",
Expand All @@ -29,6 +30,7 @@
"rollup": "^4.24.0",
"rollup-plugin-dts": "^6.1.1",
"slash": "^5.1.0",
"solid-js": "^1.9.3",
"sort-package-json": "^2.10.1",
"ts-node": "^10.9.2",
"tsup": "^8.3.0",
Expand Down
6 changes: 3 additions & 3 deletions packages/frameworks/solid-vite/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "storybook-solidjs-vite",
"type": "module",
"version": "1.0.0-beta.3",
"version": "1.0.0-beta.4",
"description": "Storybook for SolidJS and Vite: Develop SolidJS in isolation with Hot Reloading.",
"keywords": [
"storybook"
Expand Down Expand Up @@ -45,7 +45,7 @@
"*.d.ts"
],
"scripts": {
"prepack": "yarn build",
"prepack": "yarn build --optimized --reset",
"build": "npx jiti ../../../scripts/prepare/build.ts",
"check": "npx jiti ../../../scripts/prepare/check.ts"
},
Expand All @@ -56,7 +56,7 @@
},
"devDependencies": {
"@storybook/types": "next",
"solid-js": "^1.9.2",
"solid-js": "^1.9.3",
"storybook": "next",
"storybook-solidjs": "workspace:*",
"vite": "^5.4.8"
Expand Down
12 changes: 7 additions & 5 deletions packages/renderers/solid/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "storybook-solidjs",
"type": "module",
"version": "1.0.0-beta.3",
"version": "1.0.0-beta.4",
"description": "Storybook SolidJS renderer",
"keywords": [
"storybook"
Expand Down Expand Up @@ -42,7 +42,7 @@
"*.d.ts"
],
"scripts": {
"prepack": "yarn build",
"prepack": "yarn build --optimized --reset",
"build": "npx jiti ../../../scripts/prepare/build.ts",
"check": "npx jiti ../../../scripts/prepare/check.ts"
},
Expand All @@ -52,13 +52,12 @@
"@storybook/preview-api": "next",
"@storybook/test": "next",
"@storybook/types": "next",
"@types/babel__standalone": "link:.yarn/cache/null",
"async-mutex": "^0.5.0",
"esbuild": "^0.24.0",
"esbuild-plugin-solid": "^0.6.0",
"storybook": "next"
},
"peerDependencies": {
"solid-js": "^1.9.2"
"solid-js": "^1.9.3"
},
"engines": {
"node": ">=18.0.0"
Expand All @@ -74,5 +73,8 @@
"./src/entry-preview-docs.tsx"
],
"platform": "browser"
},
"dependencies": {
"@babel/standalone": "^7.26.2"
}
}
14 changes: 0 additions & 14 deletions packages/renderers/solid/src/docs/jsxDecorator.tsx

This file was deleted.

215 changes: 215 additions & 0 deletions packages/renderers/solid/src/docs/sourceDecorator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
/* eslint-disable @typescript-eslint/no-explicit-any */

// @babel/standalone does not export types,
// so this file is a mess of anys.

import type { StoryContext, PartialStoryFn } from '@storybook/types';
import { SolidRenderer } from '../types';

import { SNIPPET_RENDERED, SourceType } from '@storybook/docs-tools';
import { addons, useEffect } from '@storybook/preview-api';

// @ts-expect-error Types are not up to date
import * as Babel from '@babel/standalone';
const parser = Babel.packages.parser;
const generate = Babel.packages.generator.default;
const t = Babel.packages.types;

function skipSourceRender(context: StoryContext<SolidRenderer>): boolean {
const sourceParams = context?.parameters.docs?.source;
const isArgsStory = context?.parameters.__isArgsStory;

// always render if the user forces it
if (sourceParams?.type === SourceType.DYNAMIC) {
return false;
}

// never render if the user is forcing the block to render code, or
// if the user provides code, or if it's not an args story.
return (
!isArgsStory || sourceParams?.code || sourceParams?.type === SourceType.CODE
);
}

/**
* Generate JSX source code from stories.
*/
export const sourceDecorator = (
storyFn: PartialStoryFn<SolidRenderer>,
ctx: StoryContext<SolidRenderer>,
) => {
// Strategy: Since SolidJS doesn't have a VDOM,
// it isn't possible to get information directly about inner components.
// Instead, there needs to be an altered render function
// that records information about component properties,
// or source code extraction from files.
// This decorator uses the latter technique.
// By using the source code string generated by CSF-tools,
// we can then parse the properties of the `args` object,
// and return the source slices.

// Note: this also means we are limited in how we can
// get the component name.
// Since Storybook doesn't do source code extraction for
// story metas (yet), we can use the title for now.
const channel = addons.getChannel();
const story = storyFn();
const skip = skipSourceRender(ctx);

// eslint-disable-next-line prefer-const
let source: string | null;

useEffect(() => {
if (!skip && source) {
const { id, unmappedArgs } = ctx;
channel.emit(SNIPPET_RENDERED, { id, args: unmappedArgs, source });
}
});

if (skip) return story;

const docs = ctx?.parameters?.docs;
const src = docs?.source?.originalSource;
const name = ctx.title.split('/').at(-1)!;

source = generateSolidSource(name, src);
console.log(source);

return story;
};

/**
* Generate Solid JSX from story source.
*/
function generateSolidSource(name: string, src: string): string | null {
try {
const { attributes, children } = parseProps(src);

const selfClosing = children == null || children.length == 0;

const component = {
type: 'JSXElement',
openingElement: {
type: 'JSXOpeningElement',
name: {
type: 'JSXIdentifier',
name,
},
attributes: attributes,
selfClosing,
},
children: children ?? [],
closingElement: selfClosing
? undefined
: {
type: 'JSXClosingElement',
name: {
type: 'JSXIdentifier',
name,
},
},
};

console.log(component);

return generate(component, { compact: false }).code;
} catch (e) {
console.error(e);
return null;
}
}

function toJSXChild(node: any): object {
if (
t.isJSXElement(node) ||
t.isJSXText(node) ||
t.isJSXExpressionContainer(node) ||
t.isJSXSpreadChild(node) ||
t.isJSXFragment(node)
) {
return node;
}

if (t.isStringLiteral(node)) {
return {
type: 'JSXText',
value: node.value,
};
}

if (t.isExpression(node)) {
return {
type: 'JSXExpressionContainer',
value: node,
};
}

return {
type: 'JSXExpressionContainer',
value: t.jsxEmptyExpression(),
};
}

interface SolidProps {
attributes: object[];
children: object[] | null;
}

/**
* Parses component properties from source expression.
*
* The source code will be in the form of a `Story` object.
*/
function parseProps(src: string): SolidProps {
const ast = parser.parseExpression(src, { plugins: ['jsx'] });
console.log(ast);
if (ast.type != 'ObjectExpression') throw 'Expected `ObjectExpression` type';
// Find args property.
const args_prop = ast.properties.find((v: any) => {
if (v.type != 'ObjectProperty') return false;
if (v.key.type != 'Identifier') return false;
return v.key.name == 'args';
}) as any | undefined;
// No args just there aren't any properties or children.
if (!args_prop)
return {
attributes: [],
children: null,
};
// Get arguments.
const args = args_prop.value;
if (args.type != 'ObjectExpression') throw 'Expected `ObjectExpression` type';

// Construct props object, where values are source code slices.
const attributes: object[] = [];
let children: object[] | null = null;
for (const el of args.properties) {
if (el.type != 'ObjectProperty') continue;
if (el.key.type != 'Identifier') continue;

if (el.key.name == 'children') {
children = [toJSXChild(el.value)];
continue;
}

let value: any = {
type: 'JSXExpressionContainer',
expression: el.value,
};

if (el.value.type == 'BooleanLiteral' && el.value.value == true) {
value = undefined;
}

attributes.push({
type: 'JSXAttribute',
name: {
type: 'JSXIdentifier',
name: el.key.name,
},
value,
});
}

return { attributes, children };
}
Loading