Skip to content

Commit

Permalink
Merge pull request #128 from basehub-ai/svg-server-side
Browse files Browse the repository at this point in the history
[SVG] - Server side
  • Loading branch information
julianbenegas authored Dec 20, 2024
2 parents 1ca9e59 + 78bbd67 commit c385898
Show file tree
Hide file tree
Showing 3 changed files with 4,813 additions and 3,740 deletions.
3 changes: 1 addition & 2 deletions packages/basehub/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
"@basehub/mutation-api-helpers": "2.0.7",
"@radix-ui/react-slot": "^1.1.0",
"@shikijs/transformers": "1.17.7",
"@xmldom/xmldom": "^0.9.6",
"arg": "5.0.1",
"dotenv-mono": "1.3.10",
"esbuild": "0.19.2",
Expand All @@ -70,7 +71,6 @@
"shiki": "1.17.7",
"sonner": "^1.7.1",
"typesense": "^1.8.2",
"xmldom": "^0.6.0",
"zod": "^3.22.1"
},
"devDependencies": {
Expand All @@ -79,7 +79,6 @@
"@types/node": "18.13.0",
"@types/react": "18.2.20",
"@types/react-dom": "18.2.7",
"@types/xmldom": "^0.1.34",
"esbuild-scss-modules-plugin": "^1.1.1",
"next": "^13.5.3",
"pkg-pr-new": "^0.0.30",
Expand Down
95 changes: 33 additions & 62 deletions packages/basehub/src/react/svg/primitive.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as React from "react";
import * as z from "zod";
import { DOMParser } from "xmldom";
import { DOMParser, Element as XMLElement } from "@xmldom/xmldom";

export const supportedSvgTags = [
"svg",
Expand All @@ -18,6 +18,7 @@ export const supportedSvgTags = [
"feOffset",
"feGaussianBlur",
"feBlend",
"feComposite",
"mask",
"defs",
] as const;
Expand All @@ -42,6 +43,7 @@ const DEFAULT_COMPONENTS: ComponentsOverride = {
filter: (props) => React.createElement("filter", props),
feFlood: (props) => React.createElement("feFlood", props),
feColorMatrix: (props) => React.createElement("feColorMatrix", props),
feComposite: (props) => React.createElement("feComposite", props),
feOffset: (props) => React.createElement("feOffset", props),
feGaussianBlur: (props) => React.createElement("feGaussianBlur", props),
feBlend: (props) => React.createElement("feBlend", props),
Expand All @@ -67,20 +69,16 @@ const sanitizeSVGString = (svgString: string): string => {
return sanitized;
};

// Helper function to convert style string to React style object
const parseStyleString = (styleString: string): React.CSSProperties => {
return styleString
.split(";")
.filter((style) => style.trim() !== "")
.reduce((styleObj, style) => {
const [property, value] = style.split(":").map((s) => s.trim());
if (property && value) {
// Convert CSS property names to camelCase
const camelCaseProperty = property.replace(/-([a-z])/g, (_, letter) =>
letter.toUpperCase()
);

// Special handling for numeric values
(styleObj as any)[camelCaseProperty] =
/^\d+(\.\d+)?(px|em|rem|%)?$/.test(value) ? parseFloat(value) : value;
}
Expand All @@ -102,114 +100,87 @@ export const SVG = ({
}) => {
const content = _content ?? children;

// Merge default components with custom ones
const finalComponents = { ...DEFAULT_COMPONENTS, ...components };
const parseAndRenderSVG = React.useMemo(() => {
const finalComponents = { ...DEFAULT_COMPONENTS, ...components };

const parseAndRenderSVG = (svgString: string) => {
try {
// Sanitize the SVG string first
const sanitizedSvgString = sanitizeSVGString(svgString);

// Create a DOM parser
const parser =
typeof window !== "undefined"
? new DOMParser()
: new (require("xmldom").DOMParser)();
const sanitizedSvgString = sanitizeSVGString(content);

// Parse with error handling
const doc = parser.parseFromString(sanitizedSvgString, "image/svg+xml");
const doc = new DOMParser().parseFromString(
sanitizedSvgString,
"image/svg+xml"
);

// Check for parsing errors
const parseErrors = doc.getElementsByTagName("parsererror");
if (parseErrors.length > 0) {
throw new Error(`XML Parsing Error: ${parseErrors[0].textContent}`);
throw new Error(`XML Parsing Error: ${parseErrors[0]?.textContent}`);
}

const svgElement = doc.documentElement;

// Recursive function to convert DOM nodes to React elements
const convertNode = (node: Element): React.ReactNode => {
// Skip text nodes that only contain whitespace
const convertNode = (node: XMLElement | Element): React.ReactNode => {
if (node.nodeType === 3 && !node.nodeValue?.trim()) {
return null;
}

// For text nodes, return the text content
if (node.nodeType === 3) {
return node.nodeValue;
}

// Get the tag name and convert to lowercase
const tagName = node.tagName;

// Skip if not a valid tag
if (!tagName) return null;
const parsedTagName = svgComponentSchema.safeParse(tagName);

// Get the component for this tag
const parsedTagName = svgComponentSchema.safeParse(tagName);
const tag = parsedTagName.success
? parsedTagName.data
: (tagName as SvgComponent);
const Component = finalComponents[tag];

// Convert attributes to props
const props: Record<string, JSX.IntrinsicElements[typeof tag]> = {};
Array.from(node.attributes || []).forEach((attr) => {
const attributeValue =
attr.value as JSX.IntrinsicElements[typeof tag];
const Component = finalComponents[tag] || DEFAULT_COMPONENTS[tag];

// Skip data attributes from camel-casing
const props: Record<string, any> = {};
const attributes = Array.prototype.slice.call(node.attributes || []);
attributes.forEach((attr: any) => {
if (attr.name.startsWith("data-")) {
props[attr.name] = attributeValue;
props[attr.name] = attr.value;
return;
}

// Convert kebab-case to camelCase for React, excluding data-attributes
const name = attr.name.replace(/-([a-z])/g, (g) =>
const name = attr.name.replace(/-([a-z])/g, (g: string[]) =>
(g?.[1] as string).toUpperCase()
);

if (name === "class") {
props.className = attributeValue;
return;
}

// Special handling for style attribute
if (name === "style") {
props[name] = parseStyleString(attributeValue as string) as any;
return;
props.className = attr.value;
} else if (name === "style") {
props[name] = parseStyleString(attr.value);
} else {
props[name] = attr.value;
}

props[name] = attr.value as JSX.IntrinsicElements[typeof tag];
});

// Convert children
const children = Array.from(node.childNodes)
.map((child, index) => (
<React.Fragment key={index}>
{convertNode(child as Element)}
</React.Fragment>
const children = Array.prototype.slice
.call(node.childNodes)
.map((child: any, index) => (
<React.Fragment key={index}>{convertNode(child)}</React.Fragment>
))
.filter(Boolean);

if (typeof Component !== "function") return null;

// Return the React element
return children.length === 0
? Component(props)
: Component({ ...props, children });
};

if (!svgElement) return null;

return convertNode(svgElement);
} catch (error) {
console.error("SVG Parsing Error:", error);
return null;
}
};
}, [content, components]);

// Return the parsed and rendered SVG
const renderedSvg = parseAndRenderSVG(content);
if (!renderedSvg) return null;
if (!parseAndRenderSVG) return null;

return renderedSvg as React.ReactElement;
return parseAndRenderSVG as React.ReactElement;
};
Loading

0 comments on commit c385898

Please sign in to comment.