Skip to content

Commit

Permalink
refactor & cleanup
Browse files Browse the repository at this point in the history
  • Loading branch information
xeho91 committed May 24, 2024
1 parent 6a16b9c commit 1482015
Show file tree
Hide file tree
Showing 17 changed files with 329 additions and 246 deletions.
3 changes: 2 additions & 1 deletion src/components/StoriesExtractor.svelte
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
<script lang="ts" generics="Component extends SvelteComponent">
import type { Meta } from '@storybook/svelte';
import type { ComponentType, SvelteComponent } from 'svelte';
import { type StoriesRepository, createStoriesExtractorContext } from './context.svelte.js';
interface Props {
Stories: Component extends SvelteComponent ? ComponentType<Component> : never;
repository: () => StoriesRepository<Component>;
repository: () => StoriesRepository<Meta<Component>>;
}
const { Stories, repository }: Props = $props();
Expand Down
1 change: 1 addition & 0 deletions src/components/StoryRenderer.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
};
let { Stories, storyName, args, storyContext }: Props = $props();
const context = useStoryRenderer<M>();
$effect(() => {
Expand Down
6 changes: 3 additions & 3 deletions src/components/context.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { Meta, StoryObj, StoryContext } from '@storybook/svelte';
import type { StoryName } from '@storybook/types';
import { getContext, hasContext, setContext, type Snippet } from 'svelte';

import type { Args, Story } from '../index.js';
import type { Story } from '../index.js';

const KEYS = {
extractor: 'storybook-stories-extractor-context',
Expand Down Expand Up @@ -145,9 +145,9 @@ export function useStoriesTemplate<M extends Meta>() {
return getContext<StoriesTemplateContext<M>>(KEYS.renderSnippet).template;
}

type InferMeta<S extends Story> = S extends Story<infer M extends Meta> ? M : never;
type InferMeta<S extends Story<Meta>> = S extends Story<infer M extends Meta> ? M : never;

export function setTemplate<S extends Story>(
export function setTemplate<S extends Story<Meta>>(
snippet?: StoriesTemplateContext<InferMeta<S>>['template']
): void {
if (!hasContext(KEYS.renderSnippet)) {
Expand Down
7 changes: 7 additions & 0 deletions src/parser/ast.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { compile, type Root } from 'svelte/compiler';

export function getAST(source: string) {
const { ast }: { ast: Root } = compile(source, { modernAst: true });

return ast;
}
20 changes: 10 additions & 10 deletions src/parser/collect-stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ import { combineTags } from '@storybook/csf';
import { logger } from '@storybook/client-logger';
import { combineArgs, combineParameters } from '@storybook/preview-api';
import type { Meta, StoryFn } from '@storybook/svelte';
import { type SvelteComponent, mount, unmount } from 'svelte';
import { type SvelteComponent, mount, unmount, type ComponentType } from 'svelte';

import type { StoriesFileMeta } from './types.js';
import type { StoriesRepository } from '../components/context.svelte.js';

import type { StoriesRepository } from '../components/context.svelte.js';
import StoriesExtractor from '../components/StoriesExtractor.svelte';
import StoryRenderer from '../components/StoryRenderer.svelte';

Expand All @@ -28,15 +28,15 @@ const createFragment = document.createDocumentFragment
* the one selected is disabled.
*/
export default <M extends Meta>(
Stories: SvelteComponent,
Stories: ComponentType,
storiesFileMeta: StoriesFileMeta,
meta: M
) => {
if (!meta.parameters?.docs?.description?.component && storiesFileMeta.module.description) {
if (!meta.parameters?.docs?.description?.component && storiesFileMeta.defineMeta.description) {
meta.parameters = combineParameters(meta.parameters, {
docs: {
description: {
component: storiesFileMeta.module.description,
component: storiesFileMeta.defineMeta.description,
},
},
});
Expand All @@ -60,17 +60,17 @@ export default <M extends Meta>(
logger.error(`Error in mounting stories ${e.toString()}`, e);
}

const stories: Record<string, StoryFn<StoryRenderer>> = {};
const stories: Record<string, StoryFn<StoryRenderer<M>>> = {};

for (const [name, story] of repository.stories) {
const storyMeta = storiesFileMeta.fragment.stories[name];
const storyMeta = storiesFileMeta.stories[name];

// NOTE: We can't use StoryObj, because `@storybook/svelte` accepts `StoryFn` for now
const storyFn: StoryFn<StoryRenderer> = (args, storyContext) => {
const storyFn: StoryFn<StoryRenderer<M>> = (args, storyContext) => {
return {
Component: StoryRenderer,
Component: StoryRenderer<M>,
props: {
storyName: story.name,
storyName: story.name ?? 'Default',
Stories,
storyContext,
args,
Expand Down
15 changes: 15 additions & 0 deletions src/parser/extract-ast-nodes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { Script } from 'svelte/compiler';

import type { AddonASTNodes } from './types.js';
import { walkOnModule } from './walkers/module.js';

/**
* Pick only required AST nodes for further usage in this addon.
*/
export function extractASTNodes(module: Script | null): AddonASTNodes {
if (!module) {
throw new Error(`The stories file must have a module tag ('<script context="module">').`);
}

return walkOnModule(module);
}
30 changes: 17 additions & 13 deletions src/parser/extract-stories.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,31 @@
import { compile, type Root } from 'svelte/compiler';
import type { Fragment } from 'svelte/compiler';

import type { StoriesFileMeta } from './types.js';
import { walkOnModule } from './walkers/module.js';
import type { AddonASTNodes, StoriesFileMeta } from './types.js';
import { walkOnFragment } from './walkers/fragment.js';
import { walkOnDefineMeta } from './walkers/define-meta.js';

/**
* Parse raw stories file component in Svelte format,
* and extract the most stories file meta,
* which are required to generate `StoryFn's` for `@storybook/svelte` components.
*/
export function extractStories(rawSource: string): StoriesFileMeta {
const { ast }: { ast: Root } = compile(rawSource, { modernAst: true });
const { module, fragment } = ast;

const moduleMeta = walkOnModule(module);
const fragmentMeta = walkOnFragment({
export function extractStories({
nodes,
fragment,
source,
}: {
nodes: AddonASTNodes;
fragment: Fragment;
source: string;
}): StoriesFileMeta {
const { stories } = walkOnFragment({
fragment,
rawSource,
addonComponentName: moduleMeta.addonComponentName,
source: source,
nodes,
});

return {
module: moduleMeta,
fragment: fragmentMeta,
defineMeta: walkOnDefineMeta(nodes),
stories,
};
}
64 changes: 46 additions & 18 deletions src/parser/types.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,48 @@
import type { Meta } from '@storybook/svelte';
import type { VariableDeclarator } from 'estree';

import type { defineMeta } from '../index.js';

export const ADDON_FN_NAME = 'defineMeta';
export const ADDON_COMPONENT_NAME = 'Story';
export const ADDON_META_VAR_NAME = 'meta';
import type { Identifier, ImportSpecifier, VariableDeclaration } from 'estree';

/**
* Data extracted from the static analytic of a single stories file - `*.stories.svelte`.
*/
export interface StoriesFileMeta {
module: ModuleMeta;
fragment: FragmentMeta;
defineMeta: DefineMeta;
stories: Record<StoryMeta['id'], StoryMeta>;
}

export const ADDON_AST_NODES = {
defineMeta: 'defineMeta',
Story: 'Story',
} as const;

/**
* AST nodes extracted from the AST compile `(svelte.compile)` needed for further code transformation.
*/
export interface AddonASTNodes {
/**
* Import specifier for `defineMeta` imported from this addon package.
* Could be renamed - e.g. `import { defineMeta } from "@storybook/addon-svelte-csf"`
*/
defineMetaImport: ImportSpecifier;
/**
* Variable declarator called by `defineMeta({})` function call.
* Could be destructured with rename - e.g. `const { Story: S} = defineMeta({ ... })`
*/
defineMetaVar: VariableDeclaration;
/**
* A `<Story />` component, could be destructured with rename - e.g. `const { Story: S} = defineMeta({ ... })`
*/
Story: Identifier;
}

/**
* Meta extracted from static analysis of the module tag _(`<script context="module">`)_
* from the single stories file - `*.stories.svelte`.
*/
export interface ModuleMeta extends Pick<Meta, 'tags'> {
/**
* Description for the stories file, extracted from above `defineMeta` function call.
*/
description?: string;
// NOTE: Why? It could be overriden with `import { defineMeta as d } ...`
addonFnName: typeof defineMeta.name | (string & {});
// NOTE: Why? It could be overriden with `const { Story: S } ...`
addonComponentName: typeof ADDON_COMPONENT_NAME | (string & {});
// NOTE: Why? It could be optionally used, and overriden with `const { meta: m } ...`
addonMetaVarName?: typeof ADDON_META_VAR_NAME | (string & {}) | undefined;
defineMetaVariableDeclarator: VariableDeclarator;
}

/**
Expand All @@ -39,7 +54,20 @@ export interface FragmentMeta {
}

/**
* Meta extracted from static analysis of the single <Story /> component
* Meta extracted from static analysis of the `defineMeta` function call
* inside the module tag _(`script context="module">`)_ in the stories file - `*.stories.svelte`.
* NOTE: Properties from Meta are needed for `StoriesIndexer`
*/
export interface DefineMeta extends Pick<Meta, 'id' | 'title' | 'tags'> {
/**
* Description for the stories file.
* Extracted from the leading comment above `defineMeta` function call.
*/
description?: string;
}

/**
* Meta extracted from static analysis of the single `<Story />` component
* in the stories file - `*.stories.svelte`.
*/
export interface StoryMeta {
Expand All @@ -56,6 +84,6 @@ export interface StoryMeta {
* Description of the story, will display above the sample in docs mode.
*/
description?: string;
/** Raw source for children _(what is inside the <Story>...</Story> tags)_ */
/** Raw source for children _(what is inside the `<Story>...</Story>` tags)_ */
rawSource?: string;
}
114 changes: 114 additions & 0 deletions src/parser/walkers/define-meta.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { logger } from '@storybook/client-logger';
import dedent from 'dedent';
import type { SvelteNode } from 'svelte/compiler';
import type { ObjectExpression, Property } from 'estree';
import { walk, type Visitors } from 'zimmerframe';

import type { AddonASTNodes, DefineMeta } from '../types.js';

export function walkOnDefineMeta(nodes: AddonASTNodes): DefineMeta {
const state: Partial<DefineMeta> = {};
const visitors: Visitors<SvelteNode, typeof state> = {
// Walk on `const { ... } = defineMeta()`
VariableDeclaration(node, { state, visit }) {
const { declarations, leadingComments } = node;

if (leadingComments) {
state.description = dedent(leadingComments[0].value.replaceAll(/^ *\*/gm, ''));
}

const declaration = declarations[0];
const { id, init } = declaration;

if (
id.type === 'ObjectPattern' &&
init?.type === 'CallExpression' &&
init.callee.type === 'Identifier'
) {
visit(init, state);
}
},

// Walk on `defineMeta` function call - called by `visit()` from `VariableDeclarator`
CallExpression(node, { state, stop }) {
if (node.arguments.length !== 1) {
throw new Error(
`Function '${nodes.defineMetaImport.local.name}({ ... })' takes 1 argument only`
);
}

if (node.arguments[0].type !== 'ObjectExpression') {
throw new Error(
`Function '${nodes.defineMetaImport?.local.name}({ ... })' takes an object literal which satisfies Meta`
);
}

const { properties } = node.arguments[0];

state.id = getString('id', properties);
state.title = getString('title', properties);
state.tags = getTags(properties);

stop();
},
};

walk(nodes.defineMetaVar, state, visitors);

return state;
}

function getString(propertyName: string, properties: ObjectExpression['properties']) {
const property = lookupProperty(propertyName, properties);

if (property && property.value.type === 'Literal') {
const { value } = property.value;

if (value) {
if (typeof value === 'string') {
return value;
}

throw new Error(`'meta.${propertyName}' should be a string, found ${typeof value}`);
}
}
}

function getTags(properties: ObjectExpression['properties']) {
const tags = lookupProperty('tags', properties);

if (tags) {
const { value } = tags;

if (value.type === 'ArrayExpression') {
return value.elements.map((item) => {
if (item?.type === 'Literal') {
if (typeof item.value !== 'string') {
throw Error(`'meta.tags' should be an array of strings.`);
}

return item.value;
}

throw Error(`'meta.tags' should be an array of strings.`);
});
}
}
}

/**
* WARN: Potential issue, some of the properites might be extended by `SpreadElement`.
* I couldn't think of the case how it could be ever reached, if the code was already "compiled"(?).
* I'll leave a warning, to avoid confusion during usage, and in case it actually happens.
*/
function lookupProperty(name: string, properties: ObjectExpression['properties']) {
return properties.find((p) => {
if (p.type === 'SpreadElement') {
logger.warn(
`Spread operator is not supported in the 'defineMeta' literal object. Please file an issue with an use case.`
);
}

return p.type === 'Property' && p.key.type === 'Identifier' && p.key.name === name;
}) as Property | undefined;
}
Loading

0 comments on commit 1482015

Please sign in to comment.