Files
Oleg Isonen 878d6f1de1 feat: Add asset folders to asset manager and improve asset manager UX (#5892)
Closes https://github.com/webstudio-is/webstudio/issues/5877
Closes https://github.com/webstudio-is/webstudio/issues/4775


- Organize assets into nested folders.
- Navigate folders with breadcrumbs and Back controls.
- Select and manage multiple assets and folders at once.
- Drag, copy, cut, paste, duplicate, move, and delete items.
- Use context menus and familiar keyboard shortcuts for common actions.
- Search and sort assets and folders, with folders grouped first.
- Upload files directly into the active folder.
- Manage folder placement from asset and folder settings.
- Automate asset and folder management through MCP.
2026-07-17 23:39:14 +02:00

146 lines
3.9 KiB
TypeScript

import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
import { basename, extname } from "node:path";
import { createHash } from "node:crypto";
import { type Config, optimize } from "svgo";
import { convertSvgToJsx } from "@svgo/jsx";
import { pascalCase } from "change-case";
const prefixCache = new Map<string, string>();
const getHashedPrefix = (path?: string) => {
if (path === undefined) {
return "svg";
}
const cached = prefixCache.get(path);
if (cached !== undefined) {
return cached;
}
const hash = createHash("sha256").update(path).digest("hex").slice(0, 8);
prefixCache.set(path, hash);
return hash;
};
const sharedPlugins: Config["plugins"] = [
{
name: "preset-default",
params: {
overrides: {
// preserve viewBox
removeViewBox: false,
convertTransform: false,
inlineStyles: false,
cleanupIds: false,
},
},
},
// convert width/height to viewBox if missing
{ name: "removeDimensions" },
{
name: "prefixIds",
params: {
prefix: (_node: unknown, info: { path?: string }) =>
`c${getHashedPrefix(info.path)}`,
prefixClassNames: false,
},
},
];
type GenerateOptions = {
exportName: string;
file: string;
content: string;
};
const componentDefaults = new Map<string, { strokeWidth?: number }>([
["./icons/folder.svg", { strokeWidth: 1 }],
]);
export const generateStringExport = (options: GenerateOptions) => {
const { data: optimized } = optimize(options.content, {
path: options.file,
plugins: [
...sharedPlugins,
{
name: "addAttributesToSVGElement",
params: {
attributes: [
{
fill: "currentColor",
width: "100%",
height: "100%",
style: "display: block;",
},
],
},
},
],
});
return `export const ${options.exportName} = \`${optimized.trim()}\`;`;
};
const generateComponentExport = (options: GenerateOptions) => {
const defaults = componentDefaults.get(options.file);
const { jsx } = convertSvgToJsx({
target: "react-dom",
file: options.file,
svg: options.content,
svgProps: {
width: "{size}",
height: "{size}",
fill: "{fill}",
...(defaults?.strokeWidth === undefined
? {}
: { strokeWidth: "{strokeWidth}" }),
"{...props}": null,
ref: "{forwardedRef}",
},
plugins: sharedPlugins,
});
return `
export const ${options.exportName}: IconComponent = forwardRef(
({ fill = "none", size = 16${defaults?.strokeWidth === undefined ? "" : `, strokeWidth = ${defaults.strokeWidth}`}, ...props }, forwardedRef) => {
return (
${jsx}
);
}
);
${options.exportName}.displayName = "${options.exportName}";
`.trim();
};
const transformComponentName = (filename: string) => {
const name = basename(filename, extname(filename));
// digits cannot start variable name
return `${pascalCase(name, { mergeAmbiguousCharacters: true }).replace(/^[0-9]/, (char) => `_${char}`)}Icon`;
};
let stringContent = "";
let componentsContent = `
import { forwardRef } from "react";
import type { IconComponent } from "../types";
`.trimStart();
const start = process.hrtime.bigint();
let count = 0;
for (const name of await readdir("./icons")) {
if (name.endsWith(".svg")) {
count += 1;
const exportName = transformComponentName(name);
const file = `./icons/${name}`;
const content = await readFile(file, "utf-8");
stringContent +=
generateStringExport({ exportName, file, content }) + "\n\n";
componentsContent +=
generateComponentExport({ exportName, file, content }) + "\n\n";
}
}
await mkdir("./src/__generated__", { recursive: true });
await writeFile("./src/__generated__/svg.ts", stringContent);
await writeFile("./src/__generated__/components.tsx", componentsContent);
const end = process.hrtime.bigint();
console.info(`Compiled ${count} icons in ${(end - start) / BigInt(1e6)}ms`);