Last active
May 10, 2024 18:03
-
-
Save gnikyt/3d8f0043281e3ebfa72793c546c2cfe8 to your computer and use it in GitHub Desktop.
Shopify Checkout UI - Product description to React
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React from "react"; | |
import { List, ListItem, TextBlock } from "@shopify/ui-extensions-react/checkout"; | |
/** | |
* Represents open tag. | |
* @typedef {Object} OpenTag | |
* @property {number} start - Indicates the start position of the open tag. | |
* @property {number} end - Indicates the end position of the open tag. | |
* @property {string} name - Name of the tag. | |
* @property {string[]} classes - Classes of the tag. | |
*/ | |
/** | |
* Represents close tag. | |
* @typedef {Object} CloseTag | |
* @property {number} start - Indicates the start position of the end tag. | |
* @property {number} end - Indicates the end position of the end tag. | |
*/ | |
/** | |
* Represents contents of a tag. | |
* @typedef {Object} TagContents | |
* @property {string} contents - Contents between the open and close tag. | |
* @property {boolean} hasInnerHtml - Indicates if the contents has nested HTML. | |
*/ | |
/** | |
* Represents a parsed element. | |
* @typedef {Object} ParsedElement | |
* @property {string | ParsedElement[]} contents - Contents between the open and close tag. | |
* @property {string} name - Name of the tag. | |
* @property {string[]} classes - Classes of the tag. | |
*/ | |
/** | |
* Represents transformer callback. | |
* | |
* @callback Transformer | |
* @param {ParsedElement} element - Parsed element. | |
* @param {JSX.Element[]} children - React components parsed from element. | |
* @param {number} index - Current index in transforming loop, to use for `key` prop on component. | |
* @param {CallableFunction} defaultTransformer - Pre-filled default transformer. | |
*/ | |
/** | |
* Parses open tag to return that start position, end position, tag name, and classes. | |
* | |
* @param {string} html - Raw HTML to parse. | |
* | |
* @returns {OpenTag} | |
*/ | |
function parseOpenTag(html) { | |
// Open tag positions | |
const start = html.indexOf("<"); | |
const end = html.indexOf(">") + 1; | |
// Open tag's contents | |
const contents = html.substring(start, end); | |
// Extract tag name | |
const name = html.substring( | |
start + 1, | |
// Until next space or end of tag | |
contents.indexOf(" ") > -1 ? contents.indexOf(" ") : end - 1, | |
); | |
// Extract classes | |
const classMatch = contents.match(/class="([^"]+)"/); | |
const classes = classMatch === null ? [] : classMatch[1].split(" "); | |
return { | |
start, | |
end, | |
name, | |
classes, | |
}; | |
} | |
/** | |
* Parses close tag to return that start position and end position. | |
* | |
* @param {string} html - Raw HTML to parse. | |
* @param {string} name - Tag name. | |
* | |
* @returns {CloseTag} | |
*/ | |
function parseCloseTag(html, name) { | |
// Tag itself | |
const tag = `</${name}>`; | |
// End tag positions | |
const start = html.indexOf(tag); | |
const end = start + tag.length; | |
return { | |
start, | |
end, | |
}; | |
} | |
/** | |
* Get the contents of a tag, returning the content and a flag of it has inner HTML. | |
* | |
* @param {string} html - Raw HTML to parse. | |
* @param {object} openTag - Open tag parsed result. | |
* @param {object} closeTag - Close tag parsed result. | |
* | |
* @returns {TagContents} | |
*/ | |
function parseContentOfTag(html, openTag, closeTag) { | |
const contents = html.substring(openTag.end, closeTag.start).trim(); | |
const hasInnerHtml = contents.match(/<.*>.*<\/.*>/g); | |
return { | |
contents, | |
hasInnerHtml, | |
}; | |
} | |
/** | |
* Default transformer. | |
* | |
* @param {ParsedElement} element - Parsed element. | |
* @param {JSX.Element[]} children - React components. | |
* @param {number} index - Parsed element index in loop. | |
* | |
* @returns {JSX.Element} | |
*/ | |
function defaultTransformer(element, children, index) { | |
/** @type JSX.Element */ | |
let reactElement; | |
// No transformer result, handle defaults | |
switch (element.name) { | |
case "ul": { | |
reactElement = <List key={index}>{children}</List>; | |
break; | |
} | |
case "li": { | |
reactElement = <ListItem key={index}>{children}</ListItem>; | |
break; | |
} | |
case "div": { | |
reactElement = <TextBlock key={index}>{children}</TextBlock>; | |
break; | |
} | |
default: { | |
reactElement = <>No transformer to handle "{element.name}"</>; | |
} | |
} | |
return reactElement; | |
} | |
/** | |
* Walk through the HTML and parse it into an array of tag name, classes, and nested content. | |
* | |
* @param {string} html - Raw HTML to parse. | |
* @returns {ParsedElement[]} | |
*/ | |
function walk(html) { | |
// Store elements parsed | |
/** @type ParsedElement[] */ | |
const elements = []; | |
// Loop until no open tag is found | |
let shiftedHtml = html; | |
while (shiftedHtml.indexOf("<") !== -1) { | |
// Open tag and close tag | |
const openTag = parseOpenTag(shiftedHtml); | |
const closeTag = parseCloseTag(shiftedHtml, openTag.name); | |
// Contents | |
const content = parseContentOfTag(shiftedHtml, openTag, closeTag); | |
const contents = content.hasInnerHtml ? walk(content.contents) : content.contents; | |
// Save, move to next part of the HTML | |
elements.push({ | |
contents, | |
name: openTag.name, | |
classes: openTag.classes, | |
}); | |
shiftedHtml = shiftedHtml.substring(closeTag.end).trim(); | |
} | |
return elements; | |
} | |
/** | |
* Convert parsed HTML into React elements. | |
* | |
* @param {ParsedElement[]} elements - Elements to convert to React components. | |
* @param {Transformer | undefined} transformer - Custom transformation function. | |
* | |
* @returns {JSX.Element[]} | |
*/ | |
function transform(elements, transformer) { | |
let index = 0; | |
const reactElements = []; | |
for (const element of elements) { | |
if (element.contents !== "") { | |
// Parse nested elements, if nessessary | |
const children = Array.isArray(element.contents) ? ( | |
transform(element.contents, transformer) | |
) : ( | |
<>{element.contents}</> | |
); | |
// Transform to React | |
const reactElement = transformer | |
? transformer( | |
element, | |
children, | |
index, | |
( | |
(e, c, i) => () => | |
defaultTransformer(e, c, i) | |
)(element, children, index), | |
) | |
: defaultTransformer(element, children, index); | |
reactElements.push(reactElement); | |
index += 1; | |
} | |
} | |
return reactElements; | |
} | |
/** | |
* Parse description HTML of a product into Shopify React elements. | |
* | |
* @param {string} rawHtml - Raw HTML to parse and transform. | |
* @param {Transformer | undefined} transformer - Custom transformation function. | |
* | |
* @example | |
* ``` | |
* const parsed = descriptionTransformer(html); | |
* // ... | |
* <>{parsed}</> | |
* ``` | |
* | |
* @example | |
* ``` | |
* const parsed = descriptionTransformer(html, (element, children, index, defaultTransformer) => { | |
* if (element.classes.join(' ').indexOf("RichQuote") > -1) { | |
* return ( | |
* <BlockStack key={index}> | |
* <Text>Quote</Text> | |
* {children} | |
* </BlockStack> | |
* ); | |
* } | |
* return defaultTransformer(); | |
* }); | |
* // ... | |
* <>{parsed}</> | |
* ``` | |
* | |
* @returns {JSX.Element[]} | |
*/ | |
export default function descriptionTransformer(rawHtml, transformer) { | |
const cleanedRawHtml = rawHtml.replace(/<br>/g, "").trim(); | |
return transform(walk(cleanedRawHtml), transformer); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment