diff --git a/packages/basehub/package.json b/packages/basehub/package.json index 64adb01..0286b63 100644 --- a/packages/basehub/package.json +++ b/packages/basehub/package.json @@ -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", @@ -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": { @@ -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", diff --git a/packages/basehub/react-svg.d.ts b/packages/basehub/react-svg.d.ts new file mode 100644 index 0000000..40d58db --- /dev/null +++ b/packages/basehub/react-svg.d.ts @@ -0,0 +1 @@ +export * from "./dist/react-svg"; diff --git a/packages/basehub/react-svg.js b/packages/basehub/react-svg.js new file mode 100644 index 0000000..40d58db --- /dev/null +++ b/packages/basehub/react-svg.js @@ -0,0 +1 @@ +export * from "./dist/react-svg"; diff --git a/packages/basehub/src/bin/main.ts b/packages/basehub/src/bin/main.ts index 3aa947e..4f4073b 100644 --- a/packages/basehub/src/bin/main.ts +++ b/packages/basehub/src/bin/main.ts @@ -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", diff --git a/packages/basehub/src/react/index.ts b/packages/basehub/src/react/index.ts index 0dd4ff1..4d035d0 100644 --- a/packages/basehub/src/react/index.ts +++ b/packages/basehub/src/react/index.ts @@ -5,3 +5,4 @@ export { type CustomBlocksBase, type HandlerProps as RichTextHandlerProps, } from "./rich-text/primitive"; +export { SVG } from "./svg/primitive"; diff --git a/packages/basehub/src/react/svg/index.ts b/packages/basehub/src/react/svg/index.ts new file mode 100644 index 0000000..e91b71a --- /dev/null +++ b/packages/basehub/src/react/svg/index.ts @@ -0,0 +1 @@ +export * from "./primitive"; diff --git a/packages/basehub/src/react/svg/primitive.tsx b/packages/basehub/src/react/svg/primitive.tsx new file mode 100644 index 0000000..cd73c20 --- /dev/null +++ b/packages/basehub/src/react/svg/primitive.tsx @@ -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; + +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( + / { + 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; +}) => { + 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 = {}; + 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) => ( + + {convertNode(child as Element)} + + )) + .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; +}; diff --git a/packages/basehub/tsup-client.config.ts b/packages/basehub/tsup-client.config.ts index 09b01e5..07a6f52 100644 --- a/packages/basehub/tsup-client.config.ts +++ b/packages/basehub/tsup-client.config.ts @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2db7339..24e9e58 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -118,6 +118,9 @@ importers: typesense: specifier: ^1.8.2 version: 1.8.2(@babel/runtime@7.24.6) + xmldom: + specifier: ^0.6.0 + version: 0.6.0 zod: specifier: ^3.22.1 version: 3.22.1 @@ -137,6 +140,9 @@ importers: '@types/react-dom': specifier: 18.2.7 version: 18.2.7 + '@types/xmldom': + specifier: ^0.1.34 + version: 0.1.34 esbuild-scss-modules-plugin: specifier: ^1.1.1 version: 1.1.1(cssnano@5.0.17) @@ -1517,6 +1523,10 @@ packages: resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} dev: false + /@types/xmldom@0.1.34: + resolution: {integrity: sha512-7eZFfxI9XHYjJJuugddV6N5YNeXgQE1lArWOcd1eCOKWb/FGs5SIjacSYuEJuwhsGS3gy4RuZ5EUIcqYscuPDA==} + dev: true + /@typescript-eslint/eslint-plugin@6.9.0(@typescript-eslint/parser@6.9.0)(eslint@8.52.0)(typescript@5.4.5): resolution: {integrity: sha512-lgX7F0azQwRPB7t7WAyeHWVfW1YJ9NIgd9mvGhfQpRY56X6AVf8mwM8Wol+0z4liE7XX3QOt8MN1rUKCfSjRIA==} engines: {node: ^16.0.0 || >=18.0.0} @@ -6949,6 +6959,11 @@ packages: optional: true dev: true + /xmldom@0.6.0: + resolution: {integrity: sha512-iAcin401y58LckRZ0TkI4k0VSM1Qg0KGSc3i8rU+xrxe19A/BN1zHyVSJY7uoutVlaTSzYyk/v5AmkewAP7jtg==} + engines: {node: '>=10.0.0'} + dev: false + /y18n@4.0.3: resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}