UNPKG

storybook-addon-sdc

Version:

Drupal Single Directory Components as stories

442 lines (428 loc) 14.1 kB
// src/vite-plugin-storybook-yaml-stories.ts import { readdirSync as readdirSync2, readFileSync } from "fs"; import { parse as parseYaml } from "yaml"; import { basename, dirname as dirname2, extname } from "path"; // src/argsGenerator.ts import { JSONSchemaFaker } from "json-schema-faker"; var generateArgs = (schema, defs) => { return Object.entries(schema).reduce((acc, [key, property]) => { acc[key] = JSONSchemaFaker.generate(property, defs); return acc; }, {}); }; var slotsToSchemaProperties = (slots) => { return Object.fromEntries( Object.entries(slots).map(([key, value]) => [ key, { type: "string", ...value } ]) ); }; function generateStorybookArgs(content, jsonSchemaFakerOptions) { JSONSchemaFaker.option({ ignoreMissingRefs: true, failOnInvalidTypes: false, useExamplesValue: true, useDefaultValue: true, ...jsonSchemaFakerOptions }); const { props, slots, $defs } = content; const generatedArgs = { ...props?.properties && generateArgs(props.properties, $defs), ...slots && generateArgs(slotsToSchemaProperties(slots), $defs) }; return generatedArgs; } // src/argTypesGenerator.ts var schemaToArgtypes = (prop) => ({ ...prop, ...prop.enum && { control: "radio", options: prop.enum } }); var argTypesGenerator_default = (content) => { const generated = content?.props?.properties ? Object.entries(content.props.properties).reduce((acc, [key, value]) => { acc[key] = schemaToArgtypes(value); return acc; }, {}) : {}; return generated; }; // src/utils.ts import { readdirSync, existsSync } from "fs"; import { join, resolve } from "path"; var capitalize = (str) => str[0].toUpperCase() + str.slice(1); var convertToKebabCase = (str) => str.replace(/[-:]/g, ""); var getSubdirectories = (baseDir) => readdirSync(baseDir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => join(baseDir, entry.name)); var resolveComponentPath = (namespace, component) => { const baseDir = resolve("./components"); const directories = [baseDir, ...getSubdirectories(baseDir)]; const possiblePaths = directories.map( (dir) => join(dir, component, `${component}.component.yml`) ); return possiblePaths.find(existsSync); }; // src/storiesGenerator.ts var storiesGenerator_default = (stories) => Object.entries(stories).map( ([storyKey, { props = {}, slots = {}, variants = {} }]) => ` export const ${capitalize(storyKey)} = { args: { ...Basic.args, ${generateArgs2(props)} ${generateArgs2(slots, true)} ${generateVariants(variants)} }, play: async ({ canvasElement }) => { Drupal.attachBehaviors(canvasElement, window.drupalSettings); }, }; ` ).join("\n"); var generateArgs2 = (args, isSlot = false) => Object.entries(args).map(([key, value]) => `${key}: ${formatArgValue(value, isSlot)},`).join("\n"); var formatArgValue = (value, isSlot) => { if (Array.isArray(value)) { const arrayContent = value.map( (item) => item?.type === "component" ? generateComponent(item) : JSON.stringify(item) ).join(isSlot ? " + " : ", "); return `[${arrayContent}]`; } return JSON.stringify(value); }; var generateComponent = (item) => { const kebabCaseName = convertToKebabCase(item.component); const componentProps = { ...item.props, ...item.slots }; const storyArgs = item.story ? `...${kebabCaseName}.${item.story}.args` : "...{}"; return `${kebabCaseName}.default.component({...${kebabCaseName}.Basic.args, ${storyArgs}, ...${JSON.stringify(componentProps)}})`; }; var generateVariants = (variants) => { return Object.entries(variants).map( ([variantKey, variantValue]) => `${variantKey}: ${JSON.stringify(variantValue.title)},` ).join("\n"); }; // src/componentMetadata.ts import { dirname, relative } from "path"; var componentMetadata_default = (id, content) => { return { path: relative(process.cwd(), dirname(id)), machineName: id, status: content.status || "stable", name: content.name, group: content.group || "All Components" }; }; // src/validateJson.ts import { Validator } from "jsonschema"; import fetch from "node-fetch"; // src/logger.ts import pino from "pino"; var logger = pino({ transport: { target: "pino-pretty", options: { colorize: true } } }); // src/validateJson.ts var validator = new Validator(); var schemaCache = /* @__PURE__ */ new Map(); var fetchSchema = async (url) => { if (schemaCache.has(url)) { return schemaCache.get(url); } try { const response = await fetch(url); if (!response.ok) { throw new Error(`Failed to fetch schema: ${response.statusText}`); } const schema = await response.json(); schemaCache.set(url, schema); return schema; } catch (error) { logger.error("Error fetching schema:", error); throw new Error(`Could not fetch schema from ${url}`); } }; var validateJson = async (data, schemaUrl) => { const rootSchema = await fetchSchema(schemaUrl); validator.addSchema(rootSchema, schemaUrl); const unresolvedRefs = validator.unresolvedRefs; while (unresolvedRefs.length > 0) { const refUrl = unresolvedRefs.shift(); const schema = await fetchSchema(refUrl); validator.addSchema(schema, refUrl); } const validationResult = validator.validate(data, rootSchema); if (validationResult.errors.length > 0) { logger.warn(` ${data.name}.component.yml has validation errors: ${validationResult.errors.map((error) => error.stack).join("\n")} `); } }; // src/vite-plugin-storybook-yaml-stories.ts var readSDC = (filePath, defs, validate) => { const sdcSchema = { $defs: defs, ...parseYaml(readFileSync(filePath, "utf8")) }; if (typeof validate === "string" && validate.length > 0) { validateJson(sdcSchema, sdcSchema["$schema"] || validate); } return sdcSchema; }; var generateImports = (directory) => readdirSync2(directory).filter((file) => [".css", ".js", ".mjs", ".twig"].includes(extname(file))).map((file) => { const filePath = `./${file}`; return extname(file) === ".twig" ? `import COMPONENT from '${filePath}';` : `import '${filePath}';`; }).join("\n"); var dynamicImports = (stories) => { const imports = /* @__PURE__ */ new Set(); const extractComponentImports = (args) => { Object.values(args).forEach((value) => { if (Array.isArray(value)) { value.forEach((item) => { if (item.type === "component") { const [namespace, componentName] = item.component.split(":"); const resolvedPath = resolveComponentPath(namespace, componentName); const kebabCaseName = convertToKebabCase(item.component); if (resolvedPath) { imports.add( `import * as ${kebabCaseName} from '${resolvedPath}';` ); } } }); } else if (value && typeof value === "object") { extractComponentImports(value); } }); }; Object.values(stories).forEach( ({ slots = {}, props = {} }) => extractComponentImports({ ...slots, ...props }) ); return Array.from(imports).join("\n"); }; var createStoryIndex = (fileName, baseTitle, stories) => { const storiesIndex = [ { type: "story", importPath: fileName, exportName: "Basic", title: baseTitle } ]; if (stories) { Object.keys(stories).forEach((storyKey) => { storiesIndex.push({ type: "story", importPath: fileName, exportName: storyKey, title: baseTitle }); }); } return storiesIndex; }; var vite_plugin_storybook_yaml_stories_default = ({ jsonSchemaFakerOptions = {}, sdcStorybookOptions = {}, globalDefs = {} }) => ({ name: "vite-plugin-storybook-yaml-stories", async load(id) { if (!id.endsWith("component.yml")) return; try { const content = readSDC(id, globalDefs, sdcStorybookOptions.validate); const imports = generateImports(dirname2(id)); const storiesImports = dynamicImports( content.thirdPartySettings?.sdcStorybook?.stories || {} ); const metadata = componentMetadata_default(id, content); const argTypes = { componentMetadata: { table: { disable: true } }, defaultAttributes: { table: { disable: true } }, ...content.variants && { variant: { control: "select", options: Object.keys(content.variants) } }, ...argTypesGenerator_default(content) }; const args = { defaultAttributes: [ [ "data-component-id", `${sdcStorybookOptions?.namespace}:${basename(id, ".component.yml")}` ] ], componentMetadata: metadata, ...content.variants && { variant: Object.keys(content.variants)[0] }, ...generateStorybookArgs(content, jsonSchemaFakerOptions) }; const basicArgs = { ...args }; const stories = content.thirdPartySettings?.sdcStorybook?.stories ? storiesGenerator_default(content.thirdPartySettings.sdcStorybook.stories) : ""; return ` ${imports} ${storiesImports} export default { component: COMPONENT, argTypes: ${JSON.stringify(argTypes, null, 2)}, args: ${JSON.stringify(args, null, 2)}, }; export const Basic = { args: ${JSON.stringify(basicArgs, null, 2)}, play: async ({ canvasElement }) => { Drupal.attachBehaviors(canvasElement, window.drupalSettings); }, }; ${stories} `; } catch (error) { console.error(`Error loading component YAML file: ${id}`, error); throw error; } } }); var yamlStoriesIndexer = { test: /component\.yml$/, createIndex: async (fileName, { makeTitle }) => { try { const content = readSDC(fileName); const baseTitle = makeTitle(`SDC/${content.name}`); const stories = content.thirdPartySettings?.sdcStorybook?.stories; return createStoryIndex(fileName, baseTitle, stories); } catch (error) { console.error(`Error creating index for YAML file: ${fileName}`, error); throw error; } } }; // src/preset.ts import twig from "vite-plugin-twig-drupal"; import { mergeConfig } from "vite"; import { resolve as resolve2 } from "path"; import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs"; import { parse } from "yaml"; import { sync } from "glob"; import fetch2 from "node-fetch"; async function loadExternalDef(defPath) { try { if (defPath.startsWith("http://") || defPath.startsWith("https://")) { const response = await fetch2(defPath); if (!response.ok) { throw new Error(`Failed to fetch ${defPath}: ${response.statusText}`); } const content = await response.text(); return parse(content); } else { const content = readFileSync2(defPath, "utf8"); return parse(content); } } catch (error) { logger.error(`Error loading external definition from ${defPath}: ${error}`); throw error; } } function getComponentDirectories() { return sync("./components/**/*.component.yml"); } function resolveComponentPath2(namespace, component) { const componentDirectories = getComponentDirectories(); const possiblePaths = componentDirectories.map( (dir) => resolve2(`${dir}/${component}/${component}.component.yml`) ); const resolvedPath = possiblePaths.find((path) => existsSync2(path)); if (!resolvedPath) { logger.error( `Component ${component} could not be resolved in namespace ${namespace}` ); } return resolvedPath; } var defaultOptions = { validate: false // validate: // 'https://git.drupalcode.org/project/drupal/-/raw/HEAD/core/assets/schemas/v1/metadata.schema.json', }; async function loadAndMergeDefinitions(externalDefs, customDefs) { const globalDefs = {}; if (externalDefs) { await Promise.all( externalDefs.map(async (defPath) => { const def = await loadExternalDef(defPath); Object.entries(def).forEach(([component, schema]) => { globalDefs[component] = schema; }); }) ); } if (customDefs) { Object.entries(customDefs).forEach(([component, schema]) => { globalDefs[component] = schema; }); } if (Object.keys(globalDefs).length > 0) { logger.info( `Registering custom definitions: ${Object.keys(globalDefs).join(", ")}` ); } return globalDefs; } async function viteFinal(config, options) { options.sdcStorybookOptions = { ...defaultOptions, ...options.sdcStorybookOptions }; const { namespace, customDefs, externalDefs } = options.sdcStorybookOptions; const globalDefs = await loadAndMergeDefinitions(externalDefs, customDefs); return mergeConfig(config, { plugins: [ twig(options.vitePluginTwigDrupalOptions), vite_plugin_storybook_yaml_stories_default({ ...options, globalDefs }) ], resolve: { alias: [ { find: new RegExp(`${namespace}:(.*)`), // Use namespace from options replacement: (match, component) => { const resolvedPath = resolveComponentPath2(namespace, component); if (!resolvedPath) { throw new Error(`Component ${component} could not be resolved.`); } return resolvedPath; } } ] } }); } var experimental_indexers = async (existingIndexers) => [ ...existingIndexers || [], yamlStoriesIndexer ]; var previewHead = (head) => ` <style> .visually-hidden { position: absolute !important; overflow: hidden; clip: rect(1px, 1px, 1px, 1px); width: 1px; height: 1px; word-wrap: normal; } </style> <script src="https://cdn.jsdelivr.net/gh/drupal/drupal/core/misc/drupalSettingsLoader.js"></script> <script src="https://cdn.jsdelivr.net/gh/drupal/drupal/core/misc/drupal.js"></script> <script src="https://cdn.jsdelivr.net/gh/drupal/drupal/core/misc/drupal.init.js"></script> <script src="https://cdn.jsdelivr.net/npm/@drupal/once@1.0.1/dist/once.min.js"></script> ${head} `; export { experimental_indexers, previewHead, viteFinal };