storyblok / storyblok-svelte

Svelte SDK for Storyblok CMS
83 stars 12 forks source link

Load local Blok components automatically #1016

Open Radiergummi opened 1 month ago

Radiergummi commented 1 month ago

Description

I would like the SDK to offer an option to automatically register components. Having to maintain a mapping manually feels like a step back, when all the required building bloks (ha!) are already present.

Suggested solution or improvement

So, I actually built this already, and if it won't make it into the SDK itself, maybe you'd want to add it to the tutorial, or at least leave it here for others to copy.

To register components automatically, we can use a custom Vite plugin that provides a virtual module that serves as a barrel file for all Storyblok components.

The Vite plugin

Create a new, local Vite plugin in your src directory (I keep them in src/build/):

vite-plugin-storyblok-components.ts ```ts import { readdir } from 'node:fs/promises'; import { basename, resolve } from 'node:path'; import type { Dirent } from 'node:fs'; import { cwd } from 'node:process'; import type { Plugin, ResolvedConfig } from 'vite'; interface PluginOptions { componentsPath?: string; storyblokComponentsJsonFile?: string; } export function storyblokComponentsPlugin( { componentsPath = 'src/lib/components/Storyblok' }: PluginOptions, ): Plugin { // This is the identifier we can import in the application later on const virtualModuleId = 'virtual:$storyblok/components'; // Vite requires the internal virtual module identifier to start with a null // byte, indicating that it's a virtual module. const resolvedVirtualModuleId = '\0' + virtualModuleId; let config: ResolvedConfig; return { name: 'vite-plugin-storyblok-components', configResolved(resolvedConfig) { config = resolvedConfig; }, resolveId(id: string) { if (id === virtualModuleId) { return resolvedVirtualModuleId; } }, async load(id: string) { if (id !== resolvedVirtualModuleId) { return; } const root = cwd(); const components = await resolveComponents( root, componentsPath, config, ); return generateModule(components); }, }; } export default storyblokComponentsPlugin; /** * Resolve the component path * * This function resolves the component path and replaces the library alias. */ function resolveComponentPath(file: Dirent, libAlias: string | undefined) { let path = resolve(file.parentPath, file.name); if (libAlias) { path = path.replace(resolve(libAlias), '$lib'); } return path.replace(/^\//, ''); } /** * Format the component name * * This function formats the component name into a legal import identifier. */ function formatComponentName(file: Dirent) { return basename(file.name, '.svelte') .replace(/\W+(.)/g, (letter) => letter.toUpperCase()) .replace(/[^a-zA-Z]/g, ''); } /** * Resolve the components from the specified path * * This function reads the components from the specified path and generates * the import statements for each component. */ async function resolveComponents(root: string, componentsPath: string, config: ResolvedConfig) { // Resolve the base path to the specified components folder const path = resolve(root, componentsPath); // Infer the alias for the library path, if defined by SvelteKit const libAlias = config.resolve.alias.find(({ find, replacement }) => ( find === '$lib' ? path.startsWith(resolve(replacement)) : undefined )); // Recursively read Svelte components from the specified path const files = (await readdir(path, { recursive: true, withFileTypes: true, })).filter((file) => file.isFile()); // Generate the import statements for each component const imports = files .map((file) => { const componentName = formatComponentName(file); const componentPath = resolveComponentPath(file, libAlias?.replacement); return [ componentName, `import ${componentName} from "${componentPath}";`, ] as const; }); return Object.fromEntries(imports); } /** * Generate the module JavaScript code * * This function converts the components object into a module that exports * the components as an object. */ function generateModule(components: Record) { const imports = Object.values(components).join('\n'); const componentsMap = Object.keys(components).join(',\n '); return `${imports}\n\nexport const components = {\n ${componentsMap}\n};`; } ```

Configuring Vite

Now, we can add that plugin to the Vite configuration in vite.config.ts:

export default defineConfig({
    plugins: [
        storyblokComponentsPlugin({
            componentsPath: 'src/lib/components/Storyblok',
        }),
        sveltekit(),
    ],

    // ...
});

The plugin takes the path to your Storyblok components, and expects to find Svelte components named like the Blok. So, for example, if you use the default Bloks:

Blok name Component Name File Path
teaser teaser.svelte src/lib/components/Storyblok/teaser.svelte
grid grid.svelte src/lib/components/Storyblok/grid.svelte
Page Page.svelte src/lib/components/Storyblok/Page.svelte
nested nested.svelte src/lib/components/Storyblok/some/sub/dir/nested.svelte

Importing the components

Now that we've added the plugins, we can import the virtual module with our components and pass it to the Storyblok client:

import { apiPlugin, storyblokInit, useStoryblokApi } from '@storyblok/svelte';
import type { StoryblokClient } from '@storyblok/js';

// The import path corresponds to the module ID we defined in the plugin, and 
// is always prefixed with "virtual:"
import { components } from 'virtual:$storyblok/components';

export async function init(accessToken: string) {
    storyblokInit({
        accessToken,
        use: [apiPlugin],

        // Here we pass the components map to the client
        components,
    });

    return useStoryblokApi();
}

Done. Now you can simply use all components that exist in your Storyblok folder, without having to register them manually. The folder will be checked recursively, so you can maintain any structure within it you like. This could certainly be improved further (loading components from the API, checking for changes, integrating with TypeScript types out of the box, etc.), but it's a good start.
I'd love to see a simple Storyblok Vite plugin that handled this by itself, however. The process really isn't complicated, and makes the DX a lot cleaner IMO.

Additional context

No response

Validations

roberto-butti commented 1 month ago

This is huge.✨ ! Thank you @Radiergummi . I want to involve @alvarosabu here, because I think this suggestion (and potentially a new PR) can improve the DX of the Storyblok Svelte SDK. Thank you!

Roberto

Radiergummi commented 1 month ago

Do you have other ideas for a Storyblok Vite Plugin? If I was working for the Storyblok client SDK team, I'd probably design this to work for all frameworks, and accept a framework-specific adapter as a plugin option; maybe like so:

import storyblokPlugin from "@storyblok/vite";
import { vite as svelteKitAdapter } from "@storyblok/svelte";

export default defineConfig({
  plugins: [
    storyblokPlugin({
      adapter: svelteKitAdapter,
      // other options, including adapter specific 
      // through type narrowing
    }),
  ],
);

High up on my wishlist for this would be pre-rendering as much as possible for static builds, asset sync with the management API, type download, etc.
I also have some ideas around integrating deployments with Storyblok better, but that's taking things too far :)

There's probably more I haven't thought about enough yet.

If you guys are open to this, I'd contribute if I can.