(storybook) how to extract some source code using webpack

If you know storybook well, you may know what storysource addon is. The addon lets you see the caller code responsible for displaying the story (I mean the component) in preview. This looked pretty straightforward and efficient. (see https://storybooks-official.netlify.com/)

Since then, several developers wanted to edit the story while on the screen without needing an editor. The intent, as illustrated in react-live is proven to be appealing because it transforms a documentation site into a playground. (https://react-live.kitten.sh/)
One developer wrote an adaptation for react live in storybook : https://github.com/vertexbz/storybook-addon-react-live-edit.

I personally think it is useful to work with as a consumer of a library, but as a developer, the addon does not offer the possibility to change the component behavior itself.

So I am seizing the opportunity to look into the existing storysource addon and to know how we can extract a “story” as a “project”. I can do that by implementing a webpack loader which can instrument the source code while loading the modules.

The following snippet is showing how you can load a source file and its dependencies (local + external modules) recursively until you get a project ready to be extracted

import { getOptions } from ‘loader-utils’;
import path from ‘path’;
import injectDecorator from ‘../abstract-syntax-tree/inject-decorator’;


export function readStory(classLoader, inputSource) {
return readAsObject(classLoader, inputSource, true);
}

function readAsObject(classLoader, inputSource, mainFile) {
const options = getOptions(classLoader) || {};
// I will read the local import and module imports using an abstract-syntax-tree reader
const result = injectDecorator(
inputSource,
ADD_DECORATOR_STATEMENT,
classLoader.resourcePath,
{
options,
parser: options.parser || classLoader.extension,
},
classLoader.emitWarning.bind(classLoader)
);

const sourceJson = JSON.stringify(result.storySource || inputSource)
.replace(/\u2028/g, \\u2028′)
.replace(/\u2029/g, \\u2029′);

const addsMap = result.addsMap || {};
const dependencies = result.dependencies || [];
const source = mainFile ? result.source : inputSource;
//special case when I see a dependency picking a ‘storiesOf’ member
const idsToFrameworks = result.idsToFrameworks || {};
const resource = classLoader.resourcePath || classLoader.resource;

//how I discriminate between local dependencies and modules
const moduleDependencies = (result.dependencies || []).filter(d => d[0] === ‘.’ || d[0] === ‘/’);
const workspaceFileNames = moduleDependencies.map(d => path.join(path.dirname(resource), d));

return Promise.all(
workspaceFileNames.map(
d =>
new Promise(resolve =>
// all the direct dependencies will be loaded …
classLoader.loadModule(d, (err1, compiledSource, sourceMap, theModule) => {
if (err1) {
classLoader.emitError(err1);
}
// … and read as a source file
classLoader.fs.readFile(theModule.resource, (err2, dependencyInputData) => {
if (err2) {
classLoader.emitError(err2);
}
resolve({
d,
err: err1 || err2,
inputSource: dependencyInputData.toString(),
compiledSource,
sourceMap,
theModule,
});
});
})
)
)
)
.then(data =>
Promise.all(
data.map(({ inputSource: dependencyInputSource, theModule }) =>
// all the direct dependencies will be scanned recursively…
readAsObject(
Object.assign({}, classLoader, {
resourcePath: theModule.resourcePath,
resource: theModule.resource,
extension: (theModule.resource || ).split(‘.’).slice(1)[0],
}),
dependencyInputSource
)
)
).then(moduleObjects =>
// and grouped in a bigger object
Object.assign(
{},
moduleObjects.map(asObject => ({
[asObject.resource]: asObject,
}))
)
)
)
.then(localDependencies => ({
// the result will be flattened to get access to all files in one structure
resource,
source,
sourceJson,
addsMap,
idsToFrameworks, // storiesOf members mapped to storybook frameworks
dependencies: dependencies //all the dependencies of the “project”
.concat(extractDependenciesFrom(localDependencies))
.filter(d => d[0] !== ‘.’ && d[0] !== ‘/’)
.map(d => (d[0] === ‘@’ ? `${d.split(‘/’)[0]}/${d.split(‘/’)[1]}` : d.split(‘/’)[0])),
localDependencies: Object.assign( //all the local files to be extracted
Object.entries(localDependencies).map(([name, value]) => ({
[name]: { code: value.source },
})),
extractLocalDependenciesFrom(localDependencies)
),
}));
}

const ADD_DECORATOR_STATEMENT =
‘.addDecorator(withStorySource(__STORY__, __ADDS_MAP__,__MAIN_FILE_LOCATION__,__MODULE_DEPENDENCIES__,__LOCAL_DEPENDENCIES__,__SOURCE_PREFIX__,__IDS_TO_FRAMEWORKS__))’;

function extractDependenciesFrom(tree) {
return !Object.entries(tree || {}).length
? []
: Object.entries(tree)
.map(([, value]) =>
(value.dependencies || []).concat(extractDependenciesFrom(value.localDependencies))
)
.reduce((acc, value) => acc.concat(value), []);
}

function extractLocalDependenciesFrom(tree) {
return Object.assign(
{},
Object.entries(tree || {}).map(([thisPath, value]) =>
Object.assign(
{ [thisPath]: { code: value.source } },
extractLocalDependenciesFrom(value.localDependencies)
)
)
);
}

Here is the result in the editor :

Next steps are really challenging but not impossible : vue files parsing to find the pictures and templates, ability to require an entire directory (with require(‘./stories’) and to define a global config. I am not there yet, but I think this does not prevent me from working.

Leave a Reply

Your email address will not be published. Required fields are marked *

*

This site uses Akismet to reduce spam. Learn how your comment data is processed.