storybook-addon-sdc
Version:
Drupal Single Directory Components as stories
1,236 lines (1,198 loc) • 40.8 kB
JavaScript
// src/preset.ts
import { existsSync as existsSync4, statSync as statSync2 } from "fs";
import { join as join5, dirname as dirname5, relative as relative4 } from "path";
// src/vite-plugin-storybook-yaml-stories.ts
import { readdirSync as readdirSync2, readFileSync } from "fs";
import { parse as parseYaml } from "yaml";
import { join as join2, basename as basename2, dirname as dirname3, extname } from "path";
// src/logger.ts
import pino from "pino";
var logger = pino({
transport: {
target: "pino-pretty",
options: {
colorize: true
}
}
});
// src/utils.ts
import { dirname, sep } from "path";
var capitalize = (str) => str[0].toUpperCase() + str.slice(1);
var convertToKebabCase = (str) => str.replace(/[-:]/g, "");
var toAttributes = (attrs) => {
if (!attrs) return "";
return " " + Object.entries(attrs).map(([key, value]) => {
if (Array.isArray(value)) {
value = value.join(" ");
}
return `${key}="${value}"`;
}).join(" ");
};
var deriveGroupFromPath = (fileName) => {
const dir = dirname(fileName);
const parts = dir.split(sep).filter(Boolean);
const lower = parts.map((p) => p.toLowerCase());
const index = lower.lastIndexOf("components");
if (index !== -1 && parts[index + 2]) {
return parts[index + 1];
}
return "SDC";
};
var sanitizeStoryKey = (key) => {
if (/^\d/.test(key)) {
return `_${key}`;
}
return key;
};
// src/argsGenerator.ts
import { generate } from "json-schema-faker";
var createGenerateOptions = (defs, jsonSchemaFakerOptions) => {
const defsRecord = defs ?? {};
const userRefResolver = jsonSchemaFakerOptions.refResolver;
if (Object.keys(defsRecord).length === 0 && !userRefResolver) {
return jsonSchemaFakerOptions;
}
return {
...jsonSchemaFakerOptions,
refResolver: async (ref) => {
if (Object.prototype.hasOwnProperty.call(defsRecord, ref)) {
return defsRecord[ref];
}
if (userRefResolver) {
return userRefResolver(ref);
}
return void 0;
}
};
};
var generateArgs = (schema, defs, jsonSchemaFakerOptions) => {
return Object.entries(schema).reduce(
async (accPromise, [key, property]) => {
const acc = await accPromise;
const schemaWithDefs = {
...property,
...defs ? { $defs: defs } : {}
};
acc[key] = await generate(schemaWithDefs, jsonSchemaFakerOptions);
if (!Array.isArray(acc[key]) && acc[key] instanceof Object && property.type !== "object") {
acc[key] = Object.values(acc[key]);
}
return acc;
},
Promise.resolve({})
);
};
var slotsToSchemaProperties = (slots) => {
return Object.fromEntries(
Object.entries(slots).map(([key, value]) => [
key,
{ type: "string", ...value }
])
);
};
async function generateStorybookArgs(content, jsonSchemaFakerOptions) {
const { props, slots, $defs } = content;
const generateOptions = createGenerateOptions($defs, jsonSchemaFakerOptions);
const generatedArgs = {
...props?.properties && await generateArgs(props.properties, $defs, generateOptions),
...slots && await generateArgs(
slotsToSchemaProperties(slots),
$defs,
generateOptions
)
};
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/storyNodeRender.ts
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) => storyNodeRenderer.render(item));
return `new TwigSafeArray(${arrayContent.join(", ")})`;
}
return storyNodeRenderer.render(value);
};
var StoryNodeRenderService = class {
renderer = [];
register(renderers) {
renderers.forEach((renderer) => {
this.renderer.push(renderer);
this.renderer.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
});
}
render(item) {
const renderer = this.renderer.find((h) => h.appliesTo(item));
return renderer?.render ? renderer.render(item) : JSON.stringify(item);
}
};
var renderComponent = (item) => {
const kebabCaseName = convertToKebabCase(item.component);
const componentProps = `...{ ${generateArgs2(item.props ?? {}, false)}}, ...{${generateArgs2(item.slots ?? {}, true)}}`;
const storyArgs = item.story ? `...${kebabCaseName}.${capitalize(item.story)}.args` : "...{}";
return `${kebabCaseName}.default.component({...${kebabCaseName}.Basic.baseArgs, ${storyArgs}, ${componentProps}})`;
};
var renderImage = (item) => {
return JSON.stringify(
`<img src="${item.uri}"${toAttributes(item.attributes)}>`
);
};
var renderElement = (item) => {
return JSON.stringify(
`<${item.tag ?? "div"}${toAttributes(item.attributes)}> ${item.value} </${item.tag ?? "div"}>`
);
};
var renderMarkup = (item) => {
return JSON.stringify(`${item.markup}`);
};
var defaultStoryNodes = [
{
appliesTo: (item) => item?.type === "component",
render: (item) => renderComponent(item),
priority: -4
},
{
appliesTo: (item) => item?.theme === "image" || item?.type === "image",
render: (item) => renderImage(item),
priority: -1
},
{
appliesTo: (item) => item?.type === "element" || item?.theme === "element",
render: (item) => renderElement(item),
priority: -2
},
{
appliesTo: (item) => item?.type === "markup" || item?.theme === "markup",
render: (item) => renderMarkup(item),
priority: -2
}
];
var storyNodeRenderer = new StoryNodeRenderService();
storyNodeRenderer.register(defaultStoryNodes);
// src/storiesGenerator.ts
var storiesGenerator_default = (stories, componentGlobals = {}) => Object.entries(stories).map(
([
storyKey,
{
props = {},
slots = {},
variants = {},
description = "",
name = void 0,
library_wrapper = "",
parameters = void 0,
globals = void 0,
thirdPartySettings = void 0
}
]) => {
const storyParameters = thirdPartySettings?.sdcStorybook?.parameters ?? parameters ?? {};
const storyGlobals = thirdPartySettings?.sdcStorybook?.globals ?? globals ?? {};
const mergedGlobals = {
...componentGlobals,
...storyGlobals
};
const globalsBlock = Object.keys(mergedGlobals).length > 0 ? ` globals: ${JSON.stringify(mergedGlobals, null, 2)},` : "";
const capitalizedKey = capitalize(storyKey);
const exportName = capitalizedKey === "Basic" ? `Variant_${capitalizedKey}` : capitalizedKey;
return `
export const ${exportName} = {
parameters: {...${JSON.stringify(storyParameters, null, 2)}, ...{docs: {description: {story: ${JSON.stringify(description, null, 2)}}}}},
${globalsBlock}
name: ${JSON.stringify(name ?? capitalizedKey, null, 2)},
args: {
...Basic.baseArgs,
${processPropsAttributes(props)}
${generateArgs2(slots, true)}
${generateVariants(variants)}
},
${library_wrapper ? `decorators: [
(Story) => {
const wrapper = ${JSON.stringify(library_wrapper)};
if (!wrapper) return Story();
// Replace {{ _story }} with the actual story component
const wrappedHtml = wrapper.replace('{{ _story }}', Story());
return wrappedHtml;
}
],` : ""}
play: async ({ canvasElement }) => {
Drupal.attachBehaviors(canvasElement, window.drupalSettings);
},
};
`;
}
).join("\n");
var generateVariants = (variants) => {
return Object.entries(variants).map(
([variantKey, variantValue]) => `${variantKey}: ${JSON.stringify(variantValue.title)},`
).join("\n");
};
var processPropsAttributes = (props) => {
if (!props || !props.attributes || Object.keys(props.attributes).length === 0) {
return generateArgs2(props, false);
}
const { attributes, ...otherProps } = props;
const propsArgs = generateArgs2(otherProps, false);
const attributeEntries = Object.entries(attributes).map(([key, value]) => `['${key}', ${JSON.stringify(value)}]`).join(", ");
return propsArgs + (propsArgs ? "\n" : "") + `defaultAttributes: [...Basic.baseArgs.defaultAttributes || [], ${attributeEntries}],`;
};
// src/componentMetadata.ts
import { dirname as dirname2, relative } from "path";
import { cwd } from "process";
var componentMetadata_default = (id, content) => {
return {
path: relative(cwd(), dirname2(id)),
machineName: id,
status: content.status || "stable",
name: content.name,
group: content.group
};
};
// src/validateJson.ts
import { Validator } from "jsonschema";
import fetch from "node-fetch";
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/namespaces.ts
import { readdirSync, existsSync } from "fs";
import { basename, join, resolve, sep as sep2, relative as relative2 } from "path";
import { cwd as cwd2 } from "process";
import { normalizePath } from "vite";
var getProjectName = (p) => {
const fullPath = resolve(p);
const parts = fullPath.split(sep2);
const i = parts.lastIndexOf("components");
if (i < 1) {
throw new Error(`Could not find 'components' folder in path: ${fullPath}`);
}
return parts[i - 1];
};
var getAllSubdirectoriesRecursive = (baseDir) => {
const result = [];
const scan = (dir) => {
try {
const entries = readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
const fullPath = join(dir, entry.name);
result.push(fullPath);
scan(fullPath);
}
}
} catch (error) {
}
};
scan(baseDir);
return result;
};
var resolveComponentPath = (namespace, component, namespaces) => {
const baseDir = namespaces.findPath(namespace);
if (!baseDir) return void 0;
const componentFileName = `${component}.component.yml`;
const directPath = join(baseDir, "components", component, componentFileName);
if (existsSync(directPath)) return directPath;
const componentsDir = join(baseDir, "components");
const directories = [
componentsDir,
...getAllSubdirectoriesRecursive(componentsDir)
];
for (const dir of directories) {
const possiblePath = join(dir, component, componentFileName);
if (existsSync(possiblePath)) return possiblePath;
}
return void 0;
};
var Namespaces = class {
namespaces;
stripTrailingSlash = (p) => p.replace(/\/+$/g, "");
constructor(namespaceDefinition) {
this.namespaces = namespaceDefinition.namespaces ?? {};
if (namespaceDefinition.namespace) {
this.namespaces[namespaceDefinition.namespace] = cwd2();
} else {
this.namespaces[basename(cwd2())] = cwd2();
}
logger.info(`REGISTER NAMESPACES: ${JSON.stringify(this.namespaces)}`);
}
toViteAlias() {
const aliases = [];
Object.entries(this.namespaces).forEach(([namespace, path]) => {
const hasComponents = existsSync(join(path, "components"));
aliases.push({
find: "@" + namespace,
replacement: normalizePath(
hasComponents ? join(path, "components") : path
)
});
});
logger.info(`REGISTER VITE ALIASES: ${JSON.stringify(aliases)}`);
return aliases;
}
toTwigJsNamespaces() {
let namespaces = {};
for (const [ns, path] of Object.entries(this.namespaces)) {
const componentsPath = join(path, "components");
if (existsSync(componentsPath)) {
namespaces[ns] = componentsPath;
} else {
namespaces[ns] = path;
}
}
return namespaces;
}
toTwingNamespaces() {
let namespaces = {};
for (const [ns, path] of Object.entries(this.namespaces)) {
const componentsPath = join(path, "components");
if (existsSync(componentsPath)) {
namespaces[ns] = [componentsPath];
} else {
namespaces[ns] = [path];
}
}
return namespaces;
}
entries() {
return Object.entries(this.namespaces);
}
findPath = (namespace) => {
return this.namespaces[namespace] || "";
};
find = (fsPath) => {
const fp = this.stripTrailingSlash(fsPath);
let bestPath;
let bestNs;
for (const [ns, path] of Object.entries(this.namespaces)) {
const base = this.stripTrailingSlash(path);
if (fp === base || fp.startsWith(base + "/")) {
if (!bestPath || base.length > bestPath.length) {
bestPath = base;
bestNs = ns;
}
}
}
return bestNs;
};
pathToNamespace(fsPath, makeComponentIdFormat = false) {
const ns = this.find(fsPath);
if (!ns) {
throw new Error(
`Could not find valid 'namespace' for folder: ${fsPath} in namespaces: ${JSON.stringify(this.namespaces)}`
);
}
const rootPath = resolve(this.namespaces[ns]);
const componentsDir = join(rootPath, "components");
if (existsSync(componentsDir)) {
const isExact = fsPath === componentsDir;
const isChild = fsPath.startsWith(componentsDir + sep2);
if (!isExact && !isChild) {
throw new Error(
`Could not find 'components' folder in path: ${fsPath}, namespace: ${ns} (${Object.values(this.namespaces).join(", ")})`
);
}
let rel = relative2(componentsDir, fsPath);
if (sep2 !== "/") {
rel = rel.split(sep2).join("/");
}
if (makeComponentIdFormat) {
return `${ns}:${rel}`;
}
return `@${ns}/${rel}`;
}
return `@${ns}`;
}
};
var toNamespaces = (namespaceDefinition) => {
return new Namespaces(namespaceDefinition ?? {});
};
// 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, namespaces) => {
const componentName = basename2(directory);
return readdirSync2(directory).filter((file) => {
if (extname(file) === ".yml") {
return file.endsWith(".story.yml");
}
return [".css", ".js", ".mjs", ".twig"].includes(extname(file));
}).map((file) => {
const filePath = `./${file}`;
const namespace = namespaces.pathToNamespace(directory);
logger.info(`IMPORT ASSET ${directory}/${file}`);
if (extname(file) === ".twig") {
const fileName = basename2(file, ".twig");
if (fileName === componentName) {
return `import COMPONENT from '${namespace}/${file}';`;
}
logger.info(`Skipping variant template: ${file}`);
return "";
}
return `import '${filePath}';`;
}).filter(Boolean).join("\n");
};
var dynamicImports = (stories, namespaces) => {
const imports = /* @__PURE__ */ new Set();
const importComponent = (item) => {
const [namespace, componentName] = item.component.split(":");
const resolvedPath = resolveComponentPath(
namespace,
componentName,
namespaces
);
const kebabCaseName = convertToKebabCase(item.component);
if (resolvedPath) {
imports.add(`import * as ${kebabCaseName} from '${resolvedPath}';`);
}
};
const extractComponentImports = (args) => {
Object.values(args).forEach((value) => {
if (Array.isArray(value)) {
value.forEach((item) => {
if (item.type === "component") {
importComponent(item);
}
extractComponentImports(value);
});
} else if (value && typeof value === "object") {
if (value.type === "component") {
importComponent(value);
}
extractComponentImports(value);
}
});
};
Object.values(stories).forEach(
({ slots = {}, props = {} }) => extractComponentImports({ ...slots, ...props })
);
return Array.from(imports).join("\n");
};
var createStoryIndex = (fileName, baseTitle, stories, disabledStories, tags) => {
const storiesIndex = [];
const isAllDisabled = disabledStories.includes("all");
if (!isAllDisabled && !disabledStories.includes("basic")) {
storiesIndex.push({
type: "story",
importPath: fileName,
exportName: "Basic",
title: baseTitle,
tags
});
}
if (stories && !isAllDisabled) {
Object.keys(stories).forEach((storyKey) => {
if (!disabledStories.includes(storyKey)) {
const capitalizedKey = capitalize(storyKey);
const exportName = capitalizedKey === "Basic" ? `Variant_${capitalizedKey}` : capitalizedKey;
storiesIndex.push({
type: "story",
importPath: fileName,
exportName,
title: baseTitle,
tags
});
}
});
}
return storiesIndex;
};
var vite_plugin_storybook_yaml_stories_default = ({
jsonSchemaFakerOptions = {},
sdcStorybookOptions = {},
globalDefs = {},
namespaces = {}
}) => ({
name: "vite-plugin-storybook-yaml-stories",
async load(id) {
if (id.endsWith("story.yml")) {
return "";
}
if (!id.endsWith("component.yml")) return;
try {
const content = readSDC(id, globalDefs, sdcStorybookOptions.validate);
const imports = generateImports(dirname3(id), namespaces);
const previewsStories = {
...content.thirdPartySettings?.sdcStorybook?.stories || {},
...loadStoryFilesSync(id)
};
storyNodeRenderer.register(sdcStorybookOptions.storyNodesRenderer ?? []);
const storiesImports = dynamicImports(previewsStories, namespaces);
const metadata = componentMetadata_default(id, content);
const componentGlobals = content?.thirdPartySettings?.sdcStorybook?.globals ?? {};
const argTypes = {
componentMetadata: { table: { disable: true } },
defaultAttributes: { table: { disable: true } },
...content.variants && {
variant: {
control: "select",
options: Object.keys(content.variants)
}
},
...argTypesGenerator_default(content)
};
const baseArgs = {
defaultAttributes: [
["data-component-id", namespaces.pathToNamespace(dirname3(id), true)]
],
componentMetadata: metadata,
...content.variants && {
variant: Object.keys(content.variants)[0]
}
};
const generatedArgs = await generateStorybookArgs(content, jsonSchemaFakerOptions);
const args = sdcStorybookOptions.useBasicArgsForStories ? { ...baseArgs, ...generatedArgs } : baseArgs;
const basicArgs = sdcStorybookOptions.useBasicArgsForStories ? baseArgs : { ...baseArgs, ...generatedArgs };
const stories = previewsStories ? storiesGenerator_default(previewsStories, componentGlobals) : "";
return `
${imports}
${storiesImports}
class TwigSafeArray extends Array {
toString() {
return this.join('');
}
}
export default {
component: COMPONENT,
parameters: {...${JSON.stringify(content?.thirdPartySettings?.sdcStorybook?.parameters ?? {}, null, 2)}, ...{docs: {description: {component: ${JSON.stringify(content.description, null, 2)}}}}},
${Object.keys(componentGlobals).length > 0 ? `globals: ${JSON.stringify(componentGlobals, null, 2)},` : ""}
argTypes: ${JSON.stringify(argTypes, null, 2)},
args: ${JSON.stringify(args, null, 2)},
};
export const Basic = {
args: ${JSON.stringify(basicArgs, null, 2)},
baseArgs: ${JSON.stringify(args, null, 2)},
play: async ({ canvasElement }) => {
Drupal.attachBehaviors(canvasElement, window.drupalSettings);
},
};
${stories}
`;
} catch (error) {
logger.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 group = content.group || deriveGroupFromPath(fileName);
const baseTitle = makeTitle(
`${getProjectName(fileName)}/${capitalize(group)}/${content.name}`
);
const stories = content.thirdPartySettings?.sdcStorybook?.stories;
const storiesContent = loadStoryFilesSync(fileName);
const mergedStories = { ...stories, ...storiesContent };
const tags = content?.thirdPartySettings?.sdcStorybook?.tags ?? [];
const oldDisableBasicStory = content.thirdPartySettings?.sdcStorybook?.disableBasicStory;
const newDisabledStories = content.thirdPartySettings?.sdcStorybook?.disabledStories;
let disabledStories = [];
if (oldDisableBasicStory === true) {
disabledStories = ["basic"];
} else if (newDisabledStories) {
disabledStories = newDisabledStories;
}
return createStoryIndex(
fileName,
baseTitle,
mergedStories,
disabledStories,
tags
);
} catch (error) {
logger.error(`Error creating index for YAML file: ${fileName}, ${error}`);
throw error;
}
}
};
var loadStoryFilesSync = (fileName) => {
const folderPath = dirname3(fileName);
const storyFiles = readdirSync2(folderPath).filter((file) => file.endsWith(".story.yml")).map((file) => join2(folderPath, file));
return storyFiles.reduce(
(acc, file) => {
const content = readFileSync(file, "utf8");
const rawKey = basename2(file).split(".")[1];
const key = sanitizeStoryKey(rawKey);
return {
...acc,
[key]: parseYaml(content)
};
},
{}
);
};
// src/preset.ts
import { mergeConfig } from "vite";
// src/definitions.ts
import { parse } from "yaml";
import { readFileSync as readFileSync2 } from "fs";
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;
}
}
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(
`REGISTER CUSTOM DEFINITIONS ${JSON.stringify(Object.keys(globalDefs))}`
);
}
return globalDefs;
}
// src/constants.ts
var DEFAULT_ADDON_OPTIONS = {
sdcStorybookOptions: {
useBasicArgsForStories: true,
twigLib: "twig",
validate: false
// validate:
// 'https://git.drupalcode.org/project/drupal/-/raw/HEAD/core/assets/schemas/v1/metadata.schema.json',
},
vitePluginTwingDrupalOptions: {
include: /\.twig(\?.*)?$/
},
vitePluginTwigDrupalOptions: {},
jsonSchemaFakerOptions: {
failOnInvalidTypes: false,
useExamplesValue: true,
useDefaultValue: true
}
};
// src/preset.ts
import { merge as lodashMerge } from "lodash-es";
// src/vite-plugin-sdc-icon-packs.ts
import { existsSync as existsSync3 } from "fs";
import { join as join4 } from "path";
// src/icon-packs.ts
import { existsSync as existsSync2, readFileSync as readFileSync3, readdirSync as readdirSync3, statSync } from "fs";
import { join as join3, resolve as resolve2, basename as basename3, dirname as dirname4, extname as extname2, relative as relative3 } from "path";
import { parse as parseYaml2 } from "yaml";
function parseSvgFile(raw) {
const attrs = {};
const openTagMatch = raw.match(/<svg([^>]*)>/i);
if (openTagMatch) {
const attrRegex = /([\w:.-]+)=(?:"([^"]*)"|'([^']*)')/g;
let m;
while ((m = attrRegex.exec(openTagMatch[1])) !== null) {
attrs[m[1]] = m[2] ?? m[3] ?? "";
}
}
const innerMatch = raw.match(/<svg[^>]*>([\s\S]*)<\/svg>/i);
const content = innerMatch ? innerMatch[1].trim() : "";
return { content, attrs };
}
function scanDir(dir, baseDir, filterExt) {
const results = [];
let entries;
try {
entries = readdirSync3(dir, { withFileTypes: true });
} catch {
return results;
}
for (const entry of entries) {
const fullPath = join3(dir, entry.name);
if (entry.isDirectory()) {
results.push(...scanDir(fullPath, baseDir, filterExt));
} else if (entry.isFile()) {
const ext = extname2(entry.name);
if (filterExt && ext.toLowerCase() !== filterExt.toLowerCase()) continue;
results.push({
absPath: fullPath,
iconId: basename3(entry.name, ext),
group: relative3(baseDir, dir)
});
}
}
return results;
}
function resolveSourceEntry(rawSource, nsRoot) {
if (/^https?:\/\//.test(rawSource)) {
return [{ absPath: rawSource, iconId: "*", group: "" }];
}
const absPath = resolve2(nsRoot, rawSource);
const extMatch = rawSource.match(/\*(\.[^/{}*]+)$/);
const filterExt = extMatch ? extMatch[1] : null;
const wildcardIdx = rawSource.search(/[*{]/);
const dirPart = wildcardIdx >= 0 ? rawSource.substring(0, wildcardIdx) : rawSource;
const baseDir = resolve2(nsRoot, dirPart.replace(/[/\\]$/, ""));
if (!rawSource.includes("*") && !rawSource.includes("{") && existsSync2(absPath)) {
try {
if (statSync(absPath).isFile()) {
return [
{ absPath, iconId: basename3(absPath, extname2(absPath)), group: "" }
];
}
} catch {
}
}
if (!existsSync2(baseDir)) return [];
return scanDir(baseDir, baseDir, filterExt);
}
function loadIconPackFile(iconsFilePath) {
const watchFiles = [iconsFilePath];
const packs = {};
if (!existsSync2(iconsFilePath)) return { packs, watchFiles };
const nsRoot = dirname4(iconsFilePath);
const ns = basename3(iconsFilePath, ".icons.yml");
let raw;
try {
raw = parseYaml2(readFileSync3(iconsFilePath, "utf8"));
} catch {
return { packs, watchFiles };
}
if (!raw || typeof raw !== "object") return { packs, watchFiles };
const toUrl = (absPath) => `/sdc-icons/${ns}/${relative3(nsRoot, absPath).replace(/\\/g, "/")}`;
for (const [packId, packDef] of Object.entries(raw)) {
if (!packDef || typeof packDef !== "object") continue;
if (packDef.enabled === false) continue;
const extractor = packDef.extractor ?? "svg";
const rawSources = packDef.config?.sources ?? [];
const sources = [];
const sourceUrls = [];
for (const src of rawSources) {
if (/^https?:\/\//.test(src)) {
sources.push(src);
sourceUrls.push(src);
} else {
const absFile = resolve2(nsRoot, src);
const absBase = resolve2(
nsRoot,
src.replace(/[*{].*$/, "").replace(/[/\\]$/, "")
);
const isSpriteFile = extractor === "svg_sprite" && existsSync2(absFile) && statSync(absFile).isFile();
const finalAbs = isSpriteFile ? absFile : absBase;
sources.push(finalAbs);
sourceUrls.push(toUrl(finalAbs));
if (isSpriteFile) watchFiles.push(absFile);
}
}
const svgIcons = {};
const pathIcons = {};
if (extractor === "svg") {
for (const src of rawSources) {
for (const file of resolveSourceEntry(src, nsRoot)) {
if (file.iconId === "*") continue;
watchFiles.push(file.absPath);
try {
const { content, attrs } = parseSvgFile(
readFileSync3(file.absPath, "utf8")
);
svgIcons[file.iconId] = {
content,
attrs,
sourceUrl: toUrl(file.absPath),
group: file.group
};
} catch {
}
}
}
} else if (extractor === "path") {
for (const src of rawSources) {
for (const file of resolveSourceEntry(src, nsRoot)) {
if (file.iconId === "*") continue;
pathIcons[file.iconId] = {
sourceUrl: toUrl(file.absPath),
group: file.group
};
}
}
}
packs[packId] = {
packId,
label: packDef.label ?? packId,
extractor,
sources,
sourceUrls,
settings: packDef.settings ?? {},
template: packDef.template ?? "",
svgIcons,
pathIcons
};
}
return { packs, watchFiles };
}
// src/vite-plugin-sdc-icon-packs.ts
var VIRTUAL_TWIG = "virtual:sdc-icon-packs:twig";
var RESOLVED_TWIG = "\0" + VIRTUAL_TWIG;
var VIRTUAL_TWING = "virtual:sdc-icon-packs:twing";
var RESOLVED_TWING = "\0" + VIRTUAL_TWING;
var PACK_PREFIX = "\0icons-pack:";
var SHARED_HELPERS = (
/* js */
`
function _sdcBuildIconContext(DrupalAttribute, pack, iconId, settings) {
if (settings instanceof Map) settings = Object.fromEntries(settings);
var resolved = {};
var packSettings = pack.settings || {};
var settingKeys = Object.keys(packSettings);
for (var i = 0; i < settingKeys.length; i++) {
var key = settingKeys[i];
var def = packSettings[key];
resolved[key] =
settings && Object.prototype.hasOwnProperty.call(settings, key)
? settings[key]
: def && def.default !== undefined
? def.default
: '';
}
var source = '';
var content = '';
var svgAttrs = {};
var group = '';
if (pack.extractor === 'svg_sprite') {
source = pack.sourceUrls && pack.sourceUrls[0] ? pack.sourceUrls[0] : '';
} else if (pack.extractor === 'svg') {
var svgData = pack.svgIcons && pack.svgIcons[iconId];
if (svgData) {
source = svgData.sourceUrl || '';
content = svgData.content || '';
svgAttrs = svgData.attrs || {};
group = svgData.group || '';
}
} else {
var pathData = pack.pathIcons && pack.pathIcons[iconId];
if (pathData) {
source = pathData.sourceUrl || '';
group = pathData.group || '';
} else if (pack.sourceUrls && pack.sourceUrls[0]) {
source = pack.sourceUrls[0] + '/' + iconId;
}
}
var attributes = new DrupalAttribute(Object.entries(svgAttrs));
return Object.assign(
{ icon_id: iconId, source: source, content: content, attributes: attributes, group: group },
resolved
);
}
`
);
function packImports(paths) {
const imports = paths.map((p, i) => `import _p${i} from '${PACK_PREFIX}${p}';`).join("\n");
const args = paths.map((_, i) => `_p${i}`).join(", ");
return { imports, mergeExpr: args ? `Object.assign({}, ${args})` : "{}" };
}
function generateTwigModule(packFilePaths) {
const { imports, mergeExpr } = packImports(packFilePaths);
return `
import DrupalAttribute from 'drupal-attribute';
${imports}
${SHARED_HELPERS}
var _sdcIconPacks = ${mergeExpr};
export function registerIconFunction(Twig) {
if (Twig.__sdcIconRegistered) return;
Twig.__sdcIconRegistered = true;
Twig.extendFunction('icon', function(packId, iconId, settings) {
var pack = _sdcIconPacks[packId];
if (!pack || !iconId) return '';
var ctx = _sdcBuildIconContext(DrupalAttribute, pack, iconId, settings || {});
try {
var tmpl = Twig.twig({ data: pack.template });
return tmpl.render(ctx);
} catch (e) {
console.error('[SDC Icons] Error rendering icon "' + packId + ':' + iconId + '":', e);
return '';
}
});
}
`;
}
function generateTwingModule(packFilePaths) {
const { imports, mergeExpr } = packImports(packFilePaths);
return `
import { createSynchronousFunction } from 'twing';
import DrupalAttribute from 'drupal-attribute';
${imports}
${SHARED_HELPERS}
var _sdcIconPacks = ${mergeExpr};
export function registerIconFunction(env) {
// Register icon templates into the existing loader so env.render() finds them.
// createSynchronousArrayLoader (used by vite-plugin-twing-drupal's SDC loader)
// stores templates by reference \u2014 setTemplate() propagates to both the wrapper
// loader and the base array loader.
var loader = env.loader;
if (loader && typeof loader.setTemplate === 'function') {
var packIds = Object.keys(_sdcIconPacks);
for (var i = 0; i < packIds.length; i++) {
loader.setTemplate('_sdc_icon_' + packIds[i], _sdcIconPacks[packIds[i]].template);
}
}
var func = createSynchronousFunction(
'icon',
function(_twingCtx, packId, iconId, settings) {
var pack = _sdcIconPacks[packId];
if (!pack || !iconId) return '';
var ctx = _sdcBuildIconContext(DrupalAttribute, pack, iconId, settings || {});
try {
return env.render('_sdc_icon_' + packId, ctx);
} catch (e) {
console.error('[SDC Icons] Error rendering icon "' + packId + ':' + iconId + '":', e);
return '';
}
},
[
{ name: 'pack_id' },
{ name: 'icon_id' },
{ name: 'settings', defaultValue: {} },
]
);
env.addFunction(func);
}
`;
}
var TWIG_JS_MARKER = "from 'drupal-twig-extensions/twig'";
var TWIG_JS_INJECT_AFTER = "addDrupalExtensions(Twig);";
var TWING_MARKER = "createSynchronousEnvironment";
var TWING_INJECT_AFTER = "addDrupalExtensions(env);";
var INJECTED_GUARD = "_sdcRegisterIcon";
function iconPacksPlugin(namespaces) {
return {
name: "vite-plugin-sdc-icon-packs",
resolveId(id) {
if (id === VIRTUAL_TWIG) return RESOLVED_TWIG;
if (id === VIRTUAL_TWING) return RESOLVED_TWING;
if (id.startsWith(PACK_PREFIX)) return id;
},
load(id) {
if (id === RESOLVED_TWIG || id === RESOLVED_TWING) {
const packFilePaths = namespaces.entries().map(([ns, root]) => join4(root, `${ns}.icons.yml`)).filter((p) => existsSync3(p));
return id === RESOLVED_TWIG ? generateTwigModule(packFilePaths) : generateTwingModule(packFilePaths);
}
if (id.startsWith(PACK_PREFIX)) {
const filePath = id.slice(PACK_PREFIX.length);
const { packs, watchFiles } = loadIconPackFile(filePath);
watchFiles.forEach((f) => this.addWatchFile(f));
return `export default ${JSON.stringify(packs)};`;
}
},
handleHotUpdate({ modules, server }) {
const hasIconPack = modules.some(
(m) => m.id === RESOLVED_TWIG || m.id === RESOLVED_TWING || (m.id?.startsWith(PACK_PREFIX) ?? false)
);
if (hasIconPack) {
server.ws.send({ type: "full-reload" });
return [];
}
},
transform(code, id) {
if (!id.match(/\.twig(\?.*)?$/)) return null;
if (code.includes(INJECTED_GUARD)) return null;
if (code.includes(TWING_MARKER) && code.includes(TWING_INJECT_AFTER)) {
return {
code: `import { registerIconFunction as ${INJECTED_GUARD} } from '${VIRTUAL_TWING}';
` + code.replace(
TWING_INJECT_AFTER,
`${TWING_INJECT_AFTER}
${INJECTED_GUARD}(env);`
),
map: null
};
}
if (code.includes(TWIG_JS_MARKER) && code.includes(TWIG_JS_INJECT_AFTER)) {
return {
code: `import { registerIconFunction as ${INJECTED_GUARD} } from '${VIRTUAL_TWIG}';
` + code.replace(
TWIG_JS_INJECT_AFTER,
`${TWIG_JS_INJECT_AFTER}
${INJECTED_GUARD}(Twig);`
),
map: null
};
}
return null;
},
configureServer(server) {
namespaces.entries().forEach(([, root]) => server.watcher.add(root));
server.watcher.on("add", (p) => {
if (!p.endsWith(".icons.yml")) return;
for (const rid of [RESOLVED_TWIG, RESOLVED_TWING]) {
const mod = server.moduleGraph.getModuleById(rid);
if (mod) server.moduleGraph.invalidateModule(mod);
}
server.ws.send({ type: "full-reload" });
});
}
};
}
// src/preset.ts
async function viteFinal(config, options) {
options = lodashMerge({}, DEFAULT_ADDON_OPTIONS, options);
const {
sdcStorybookOptions,
vitePluginTwigDrupalOptions,
vitePluginTwingDrupalOptions
} = options;
const { customDefs, externalDefs } = sdcStorybookOptions;
const { nodePolyfills } = await import("vite-plugin-node-polyfills");
const globalDefs = await loadAndMergeDefinitions(externalDefs, customDefs);
const namespaces = toNamespaces(sdcStorybookOptions);
let twigPlugin = null;
if (sdcStorybookOptions.twigLib === "twing") {
options.vitePluginTwingDrupalOptions = {
...vitePluginTwingDrupalOptions,
namespaces: { ...namespaces.toTwingNamespaces() }
};
const { default: twing } = await import("vite-plugin-twing-drupal");
twigPlugin = twing(options.vitePluginTwingDrupalOptions);
} else if (sdcStorybookOptions.twigLib === "twig") {
options.vitePluginTwigDrupalOptions = {
...vitePluginTwigDrupalOptions,
namespaces: { ...namespaces.toTwigJsNamespaces() }
};
const { default: twig } = await import("vite-plugin-twig-drupal");
twigPlugin = twig(options.vitePluginTwigDrupalOptions);
}
return mergeConfig(config, {
plugins: [
nodePolyfills({
include: ["buffer", "stream", "path"]
}),
...twigPlugin ? [twigPlugin] : [],
vite_plugin_storybook_yaml_stories_default({ ...options, globalDefs, namespaces }),
iconPacksPlugin(namespaces)
],
optimizeDeps: {
exclude: ["vite-plugin-twig-drupal", "vite-plugin-twing-drupal"]
},
resolve: {
alias: [...namespaces.toViteAlias()]
}
});
}
var experimental_indexers = async (existingIndexers) => [
...existingIndexers || [],
yamlStoriesIndexer
];
var staticDirs = async (existing, options) => {
const merged = lodashMerge({}, DEFAULT_ADDON_OPTIONS, options);
const namespaces = toNamespaces(merged.sdcStorybookOptions);
const iconDirs = [];
for (const [ns, nsRoot] of namespaces.entries()) {
const iconsFile = join5(nsRoot, `${ns}.icons.yml`);
if (!existsSync4(iconsFile)) continue;
const { packs } = loadIconPackFile(iconsFile);
const seen = /* @__PURE__ */ new Set();
for (const pack of Object.values(packs)) {
if (pack.extractor === "svg") continue;
for (const src of pack.sources) {
if (src.startsWith("http")) continue;
if (!existsSync4(src)) continue;
const dir = statSync2(src).isDirectory() ? src : dirname5(src);
if (seen.has(dir)) continue;
seen.add(dir);
const relDir = relative4(nsRoot, dir).replace(/\\/g, "/");
iconDirs.push({ from: dir, to: `/sdc-icons/${ns}/${relDir}` });
}
}
}
return [...existing || [], ...iconDirs];
};
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,
staticDirs,
viteFinal
};