storybook-addon-sdc
Version:
Drupal Single Directory Components as stories
442 lines (428 loc) • 14.1 kB
JavaScript
// 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
};