Skip to content

Commit

Permalink
Merge pull request #123 from basehub-ai/svg
Browse files Browse the repository at this point in the history
SVG component
  • Loading branch information
julianbenegas authored Dec 13, 2024
2 parents a8910b6 + 6847d6f commit 5e0efdd
Show file tree
Hide file tree
Showing 9 changed files with 240 additions and 0 deletions.
4 changes: 4 additions & 0 deletions packages/basehub/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
"src/next/toolbar",
"src/react/pump",
"src/events",
"react-svg.js",
"react-svg.d.ts",
"react-rich-text.js",
"react-rich-text.d.ts",
"react-form.js",
Expand Down Expand Up @@ -68,6 +70,7 @@
"shiki": "1.17.7",
"sonner": "^1.7.1",
"typesense": "^1.8.2",
"xmldom": "^0.6.0",
"zod": "^3.22.1"
},
"devDependencies": {
Expand All @@ -76,6 +79,7 @@
"@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
1 change: 1 addition & 0 deletions packages/basehub/react-svg.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./dist/react-svg";
1 change: 1 addition & 0 deletions packages/basehub/react-svg.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./dist/react-svg";
1 change: 1 addition & 0 deletions packages/basehub/src/bin/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,7 @@ import type { RichTextNode, RichTextTocNode } from './api-transaction';
if (output !== "node_modules") {
// alias react-rich-text and other packages to the generated client for better import experience
[
"react-svg",
"react-rich-text",
"react-form",
"react-code-block/index",
Expand Down
1 change: 1 addition & 0 deletions packages/basehub/src/react/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ export {
type CustomBlocksBase,
type HandlerProps as RichTextHandlerProps,
} from "./rich-text/primitive";
export { SVG } from "./svg/primitive";
1 change: 1 addition & 0 deletions packages/basehub/src/react/svg/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./primitive";
215 changes: 215 additions & 0 deletions packages/basehub/src/react/svg/primitive.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
import * as React from "react";
import * as z from "zod";
import { DOMParser } from "xmldom";

export const supportedSvgTags = [
"svg",
"path",
"circle",
"rect",
"g",
"line",
"polyline",
"polygon",
"text",
"filter",
"feFlood",
"feColorMatrix",
"feOffset",
"feGaussianBlur",
"feBlend",
"mask",
"defs",
] as const;
const svgComponentSchema = z.enum(supportedSvgTags);

type SvgComponent = z.infer<typeof svgComponentSchema>;

type ComponentsOverride = {
[K in SvgComponent]: (props: JSX.IntrinsicElements[K]) => React.ReactElement;
};

const DEFAULT_COMPONENTS: ComponentsOverride = {
svg: (props) => React.createElement("svg", props),
path: (props) => React.createElement("path", props),
circle: (props) => React.createElement("circle", props),
rect: (props) => React.createElement("rect", props),
g: (props) => React.createElement("g", props),
line: (props) => React.createElement("line", props),
polyline: (props) => React.createElement("polyline", props),
polygon: (props) => React.createElement("polygon", props),
text: (props) => React.createElement("text", props),
filter: (props) => React.createElement("filter", props),
feFlood: (props) => React.createElement("feFlood", props),
feColorMatrix: (props) => React.createElement("feColorMatrix", props),
feOffset: (props) => React.createElement("feOffset", props),
feGaussianBlur: (props) => React.createElement("feGaussianBlur", props),
feBlend: (props) => React.createElement("feBlend", props),
mask: (props) => React.createElement("mask", props),
defs: (props) => React.createElement("defs", props),
};

const sanitizeSVGString = (svgString: string): string => {
// Remove any XML declaration
let sanitized = svgString.replace(/<\?xml.*\?>\s*/g, "");

// Ensure self-closing tags are properly formatted
sanitized = sanitized.replace(/\s*\/\s*>/g, "/>");

// Add namespace if missing
if (!sanitized.includes('xmlns="http://www.w3.org/2000/svg"')) {
sanitized = sanitized.replace(
/<svg/,
'<svg xmlns="http://www.w3.org/2000/svg"'
);
}

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;
}
return styleObj;
}, {} as React.CSSProperties);
};

export const SVG = ({
content: _content,
children,
components = DEFAULT_COMPONENTS,
}: {
content: string;
/**
* @deprecated Use `content` instead.
*/
children?: string;
components?: Partial<ComponentsOverride>;
}) => {
const content = _content ?? children;

// Merge default components with custom ones
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)();

// Parse with error handling
const doc = parser.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}`);
}

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
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 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];

// Skip data attributes from camel-casing
if (attr.name.startsWith("data-")) {
props[attr.name] = attributeValue;
return;
}

// Convert kebab-case to camelCase for React, excluding data-attributes
const name = attr.name.replace(/-([a-z])/g, (g) =>
(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[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>
))
.filter(Boolean);

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

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

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

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

return renderedSvg as React.ReactElement;
};
1 change: 1 addition & 0 deletions packages/basehub/tsup-client.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export default defineConfig((_options: Options) => {
minify: false,
dts: true,
entry: {
"react-svg": "./src/react/svg/index.ts",
"react-rich-text": "./src/react/rich-text/index.ts",
"react-form": "./src/react/form/index.ts",
"react-code-block/index": "./src/react/code-block/index.ts",
Expand Down
15 changes: 15 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 5e0efdd

Please sign in to comment.