Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions code/frameworks/sveltekit/build-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ const config: BuildEntries = {
},
],
},
extraOutputs: {
'./internal/MockProvider.svelte': './static/MockProvider.svelte',
},
};

export default config;
2 changes: 2 additions & 0 deletions code/frameworks/sveltekit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./internal/MockProvider.svelte": "./static/MockProvider.svelte",
"./internal/mocks/app/forms": "./dist/mocks/app/forms.js",
"./internal/mocks/app/navigation": "./dist/mocks/app/navigation.js",
"./internal/mocks/app/stores": "./dist/mocks/app/stores.js",
Expand All @@ -46,6 +47,7 @@
},
"files": [
"dist/**/*",
"static/**/*",
"template/**/*",
"README.md",
"*.js",
Expand Down
137 changes: 8 additions & 129 deletions code/frameworks/sveltekit/src/preview.ts
Original file line number Diff line number Diff line change
@@ -1,138 +1,17 @@
import type { Decorator } from '@storybook/svelte';
import MockProvider from '@storybook/sveltekit/internal/MockProvider.svelte';

import { action } from 'storybook/actions';
import { onMount } from 'svelte';

import { setAfterNavigateArgument } from './mocks/app/navigation';
import { setNavigating, setPage, setUpdated } from './mocks/app/stores';
import type { HrefConfig, NormalizedHrefConfig, SvelteKitParameters } from './types';

const normalizeHrefConfig = (hrefConfig: HrefConfig): NormalizedHrefConfig => {
if (typeof hrefConfig === 'function') {
return { callback: hrefConfig, asRegex: false };
}
return hrefConfig;
};
import type { SvelteKitParameters } from './types';

const svelteKitMocksDecorator: Decorator = (Story, ctx) => {
const svelteKitParameters: SvelteKitParameters = ctx.parameters?.sveltekit_experimental ?? {};
setPage(svelteKitParameters?.stores?.page);
setNavigating(svelteKitParameters?.stores?.navigating);
setUpdated(svelteKitParameters?.stores?.updated);
setAfterNavigateArgument(svelteKitParameters?.navigation?.afterNavigate);

onMount(() => {
const globalClickListener = (e: MouseEvent) => {
// we add a global click event listener and we check if there's a link in the composedPath
const path = e.composedPath();
const element = path.findLast((el) => el instanceof HTMLElement && el.tagName === 'A');
if (element && element instanceof HTMLAnchorElement) {
// if the element is an a-tag we get the href of the element
// and compare it to the hrefs-parameter set by the user
const to = element.getAttribute('href');
if (!to) {
return;
}
e.preventDefault();
const defaultActionCallback = () => action('navigate')(to, e);
if (!svelteKitParameters.hrefs) {
defaultActionCallback();
return;
}

let callDefaultCallback = true;
// we loop over every href set by the user and check if the href matches
// if it does we call the callback provided by the user and disable the default callback
Object.entries(svelteKitParameters.hrefs).forEach(([href, hrefConfig]) => {
const { callback, asRegex } = normalizeHrefConfig(hrefConfig);
const isMatch = asRegex ? new RegExp(href).test(to) : to === href;
if (isMatch) {
callDefaultCallback = false;
callback?.(to, e);
}
});
if (callDefaultCallback) {
defaultActionCallback();
}
}
};

/**
* Function that create and add listeners for the event that are emitted by the mocked
* functions. The event name is based on the function name
*
* Eg. storybook:goto, storybook:invalidateAll
*
* @param baseModule The base module where the function lives (navigation|forms)
* @param functions The list of functions in that module that emit events
* @param {boolean} [defaultToAction] The list of functions in that module that emit events
* @returns A function to remove all the listener added
*/
function createListeners(
baseModule: keyof SvelteKitParameters,
functions: string[],
defaultToAction?: boolean
) {
// the array of every added listener, we can use this in the return function
// to clean them
const toRemove: Array<{
eventType: string;
listener: (event: { detail: any[] }) => void;
}> = [];
functions.forEach((func) => {
// we loop over every function and check if the user actually passed
// a function in sveltekit_experimental[baseModule][func] eg. sveltekit_experimental.navigation.goto
const hasFunction =
(svelteKitParameters as any)[baseModule]?.[func] &&
(svelteKitParameters as any)[baseModule][func] instanceof Function;
// if we default to an action we still add the listener (this will be the case for goto, invalidate, invalidateAll)
if (hasFunction || defaultToAction) {
// we create the listener that will just get the detail array from the custom element
// and call the user provided function spreading this args in...this will basically call
// the function that the user provide with the same arguments the function is invoked to

// eg. if it calls goto("/my-route") inside the component the function sveltekit_experimental.navigation.goto
// it provided to storybook will be called with "/my-route"
const listener = ({ detail = [] as any[] }) => {
const args = Array.isArray(detail) ? detail : [];
// if it has a function in the parameters we call that function
// otherwise we invoke the action
const fnToCall = hasFunction
? (svelteKitParameters as any)[baseModule][func]
: action(func);
fnToCall(...args);
};
const eventType = `storybook:${func}`;
toRemove.push({ eventType, listener });
// add the listener to window
(window.addEventListener as any)(eventType, listener);
}
});
return () => {
// loop over every listener added and remove them
toRemove.forEach(({ eventType, listener }) => {
// @ts-expect-error apparently you can't remove a custom listener to the window with TS
window.removeEventListener(eventType, listener);
});
};
}

const removeNavigationListeners = createListeners(
'navigation',
['goto', 'invalidate', 'invalidateAll', 'pushState', 'replaceState'],
true
);
const removeFormsListeners = createListeners('forms', ['enhance']);
window.addEventListener('click', globalClickListener);

return () => {
window.removeEventListener('click', globalClickListener);
removeNavigationListeners();
removeFormsListeners();
};
});

return Story();
return {
Component: MockProvider,
props: {
svelteKitParameters,
},
};
};

export const decorators: Decorator[] = [svelteKitMocksDecorator];
129 changes: 129 additions & 0 deletions code/frameworks/sveltekit/static/MockProvider.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
<script>
import { onMount } from 'svelte';
import { action } from 'storybook/actions';

import { setAfterNavigateArgument } from '../src/mocks/app/navigation';
import { setNavigating, setPage, setUpdated } from '../src/mocks/app/stores';


const{ svelteKitParameters = {}, children } = $props();


// Set context during component initialization - this happens before any child components
setPage(svelteKitParameters?.stores?.page);
setNavigating(svelteKitParameters?.stores?.navigating);
setUpdated(svelteKitParameters?.stores?.updated);
setAfterNavigateArgument(svelteKitParameters?.navigation?.afterNavigate);

const normalizeHrefConfig = (hrefConfig) => {
if (typeof hrefConfig === 'function') {
return { callback: hrefConfig, asRegex: false };
}
return hrefConfig;
};
Comment on lines +18 to +23
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick

Normalize href config defensively

If a non‑object slips in, return a safe default.

Apply this diff:

-  const normalizeHrefConfig = (hrefConfig) => {
-    if (typeof hrefConfig === 'function') {
-      return { callback: hrefConfig, asRegex: false };
-    }
-    return hrefConfig;
-  };
+  const normalizeHrefConfig = (hrefConfig) => {
+    if (typeof hrefConfig === 'function') return { callback: hrefConfig, asRegex: false };
+    return hrefConfig && typeof hrefConfig === 'object'
+      ? hrefConfig
+      : { callback: undefined, asRegex: false };
+  };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const normalizeHrefConfig = (hrefConfig) => {
if (typeof hrefConfig === 'function') {
return { callback: hrefConfig, asRegex: false };
}
return hrefConfig;
};
const normalizeHrefConfig = (hrefConfig) => {
if (typeof hrefConfig === 'function') return { callback: hrefConfig, asRegex: false };
return hrefConfig && typeof hrefConfig === 'object'
? hrefConfig
: { callback: undefined, asRegex: false };
};
🤖 Prompt for AI Agents
In code/frameworks/sveltekit/static/MockProvider.svelte around lines 18 to 23,
normalizeHrefConfig should defensively handle non-object inputs: keep the
existing branch for functions, but if hrefConfig is not a non-null object return
a safe default like { callback: (href) => href, asRegex: false }; if it is an
object ensure it has a callback (defaulting to identity) and an asRegex boolean
(defaulting to false) before returning it.


onMount(() => {
const globalClickListener = (e) => {
// we add a global click event listener and we check if there's a link in the composedPath
const path = e.composedPath();
const element = path.findLast((el) => el instanceof HTMLElement && el.tagName === 'A');
if (element && element instanceof HTMLAnchorElement) {
// if the element is an a-tag we get the href of the element
// and compare it to the hrefs-parameter set by the user
const to = element.getAttribute('href');
if (!to) {
return;
}
e.preventDefault();
const defaultActionCallback = () => action('navigate')(to, e);
if (!svelteKitParameters.hrefs) {
defaultActionCallback();
return;
}

let callDefaultCallback = true;
// we loop over every href set by the user and check if the href matches
// if it does we call the callback provided by the user and disable the default callback
Object.entries(svelteKitParameters.hrefs).forEach(([href, hrefConfig]) => {
const { callback, asRegex } = normalizeHrefConfig(hrefConfig);
const isMatch = asRegex ? new RegExp(href).test(to) : to === href;
if (isMatch) {
callDefaultCallback = false;
callback?.(to, e);
}
});
if (callDefaultCallback) {
defaultActionCallback();
}
}
};

/**
* Function that create and add listeners for the event that are emitted by the mocked
* functions. The event name is based on the function name
*
* Eg. storybook:goto, storybook:invalidateAll
*
* @param baseModule The base module where the function lives (navigation|forms)
* @param functions The list of functions in that module that emit events
* @param {boolean} [defaultToAction] The list of functions in that module that emit events
* @returns A function to remove all the listener added
*/
function createListeners(baseModule, functions, defaultToAction) {
// the array of every added listener, we can use this in the return function
// to clean them
const toRemove = [];
functions.forEach((func) => {
// we loop over every function and check if the user actually passed
// a function in sveltekit_experimental[baseModule][func] eg. sveltekit_experimental.navigation.goto
const hasFunction =
svelteKitParameters[baseModule]?.[func] &&
svelteKitParameters[baseModule][func] instanceof Function;
// if we default to an action we still add the listener (this will be the case for goto, invalidate, invalidateAll)
if (hasFunction || defaultToAction) {
// we create the listener that will just get the detail array from the custom element
// and call the user provided function spreading this args in...this will basically call
// the function that the user provide with the same arguments the function is invoked to

// eg. if it calls goto("/my-route") inside the component the function sveltekit_experimental.navigation.goto
// it provided to storybook will be called with "/my-route"
const listener = ({ detail = [] }) => {
const args = Array.isArray(detail) ? detail : [];
// if it has a function in the parameters we call that function
// otherwise we invoke the action
const fnToCall = hasFunction
? svelteKitParameters[baseModule][func]
: action(func);
fnToCall(...args);
};
const eventType = `storybook:${func}`;
toRemove.push({ eventType, listener });
// add the listener to window
window.addEventListener(eventType, listener);
}
});
return () => {
// loop over every listener added and remove them
toRemove.forEach(({ eventType, listener }) => {
window.removeEventListener(eventType, listener);
});
};
}

const removeNavigationListeners = createListeners(
'navigation',
['goto', 'invalidate', 'invalidateAll', 'pushState', 'replaceState'],
true
);
const removeFormsListeners = createListeners('forms', ['enhance']);
window.addEventListener('click', globalClickListener);

return () => {
window.removeEventListener('click', globalClickListener);
removeNavigationListeners();
removeFormsListeners();
};
});
</script>

{@render children()}
1 change: 0 additions & 1 deletion code/frameworks/sveltekit/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
"paths": {
"storybook/internal/*": ["../../lib/cli/core/*"]
},
"rootDir": "./src"
},
"extends": "../../tsconfig.json",
"include": ["src/**/*"]
Expand Down
55 changes: 47 additions & 8 deletions scripts/tasks/sandbox-parts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ import { SupportedLanguage } from '../../code/core/src/cli/project_types';
import { JsPackageManagerFactory } from '../../code/core/src/common/js-package-manager';
import storybookPackages from '../../code/core/src/common/versions';
import type { ConfigFile } from '../../code/core/src/csf-tools';
import { formatConfig, writeConfig } from '../../code/core/src/csf-tools';
import {
readConfig as csfReadConfig,
formatConfig,
writeConfig,
} from '../../code/core/src/csf-tools';
import type { TemplateKey } from '../../code/lib/cli-storybook/src/sandbox-templates';
import type { PassedOptionValues, Task, TemplateDetails } from '../task';
import { executeCLIStep, steps } from '../utils/cli-step';
Expand Down Expand Up @@ -170,19 +174,24 @@ export const init: Task['run'] = async (
const cwd = sandboxDir;

let extra = {};
if (template.expected.renderer === '@storybook/html') {
extra = { type: 'html' };
} else if (template.expected.renderer === '@storybook/server') {
extra = { type: 'server' };
} else if (template.expected.framework === '@storybook/react-native-web-vite') {
extra = { type: 'react_native_web' };

switch (template.expected.renderer) {
case '@storybook/html':
extra = { type: 'html' };
break;
case '@storybook/server':
extra = { type: 'server' };
break;
case '@storybook/svelte':
await prepareSvelteSandbox(cwd);
break;
}

switch (template.expected.framework) {
case '@storybook/react-native-web-vite':
extra = { type: 'react_native_web' };
await prepareReactNativeWebSandbox(cwd);
break;
default:
}

await executeCLIStep(steps.init, {
Expand Down Expand Up @@ -878,6 +887,36 @@ async function prepareReactNativeWebSandbox(cwd: string) {
}
}

async function prepareSvelteSandbox(cwd: string) {
const svelteConfigJsPath = join(cwd, 'svelte.config.js');
const svelteConfigTsPath = join(cwd, 'svelte.config.ts');

// Check which config file exists
const configPath = (await pathExists(svelteConfigTsPath))
? svelteConfigTsPath
: (await pathExists(svelteConfigJsPath))
? svelteConfigJsPath
: null;

if (!configPath) {
throw new Error(
`No svelte.config.js or svelte.config.ts found in sandbox: ${cwd}, cannot modify config.`
);
}

const svelteConfig = await csfReadConfig(configPath);

// Enable async components
// see https://svelte.dev/docs/svelte/await-expressions
svelteConfig.setFieldValue(['compilerOptions', 'experimental', 'async'], true);

// Enable remote functions
// see https://svelte.dev/docs/kit/remote-functions
svelteConfig.setFieldValue(['kit', 'experimental', 'remoteFunctions'], true);

await writeConfig(svelteConfig);
}

async function prepareAngularSandbox(cwd: string, templateName: string) {
const angularJson = await readJson(join(cwd, 'angular.json'));

Expand Down