Skip to content

Instantly share code, notes, and snippets.

@gnikyt
Last active May 10, 2024 18:03
Show Gist options
  • Save gnikyt/3d8f0043281e3ebfa72793c546c2cfe8 to your computer and use it in GitHub Desktop.
Save gnikyt/3d8f0043281e3ebfa72793c546c2cfe8 to your computer and use it in GitHub Desktop.
Shopify Checkout UI - Product description to React
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 &quot;{element.name}&quot;</>;
}
}
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