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

Load local Blok components automatically #1016

Open
1 task done
Radiergummi opened this issue Jul 28, 2024 · 2 comments
Open
1 task done

Load local Blok components automatically #1016

Radiergummi opened this issue Jul 28, 2024 · 2 comments
Labels
enhancement [Issue][PR] New feature pending-author [Issue] Awaiting further information or action from the issue author pending-triage [Issue] Ticket is pending to be prioritised

Comments

@Radiergummi
Copy link

Radiergummi commented Jul 28, 2024

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
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<string, `import ${string} from ${string}`>) {
    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

@Radiergummi Radiergummi added enhancement [Issue][PR] New feature pending-author [Issue] Awaiting further information or action from the issue author pending-triage [Issue] Ticket is pending to be prioritised labels Jul 28, 2024
@roberto-butti
Copy link
Contributor

roberto-butti commented Aug 1, 2024

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
Copy link
Author

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement [Issue][PR] New feature pending-author [Issue] Awaiting further information or action from the issue author pending-triage [Issue] Ticket is pending to be prioritised
Projects
None yet
Development

No branches or pull requests

2 participants