@pmndrs/uikit
Version:
Build performant 3D user interfaces with Three.js and yoga.
362 lines (361 loc) • 13.9 kB
JavaScript
import { parse as parseHTML, TextNode, HTMLElement } from 'node-html-parser';
import { htmlDefaults } from './defaults.js';
import parseInlineCSS from 'inline-style-parser';
import { tailwindToCSS } from 'tw-to-css';
//@ts-ignore
import { generatedPropertyTypes } from './generated-property-types.js';
import { convertProperties, convertProperty, isInheritingProperty, toNumber, } from './properties.js';
import { MeshPhongMaterial, MeshPhysicalMaterial } from 'three';
export { Node as ConversionNode, HTMLElement as ConversionHtmlNode } from 'node-html-parser';
const styleTagRegex = /\<style\>(?:.|\s)*?\<\/style\>/gm;
export function convertHtml(text, generate, colorMap, componentMap) {
const { classes, element } = parseHtml(text, colorMap);
return convertParsedHtml(element, classes, generate, colorMap, componentMap);
}
const cssClassRegex = /\s*\.([^\{]+)\s*\{([^}]*)\}/g;
const cssPropsRegex = /([^:\s]+)\s*\:\s*([^;\s]+(?:[ \t]+[^;\s]+)*)\s*\;?\s*/g;
const spaceXYRegex = /(-?)space-(x|y)-(\d+)/g;
export class PlasticMaterial extends MeshPhongMaterial {
constructor() {
super({
specular: '#111',
shininess: 100,
});
}
}
export class GlassMaterial extends MeshPhysicalMaterial {
constructor() {
super({
transmission: 0.5,
roughness: 0.1,
reflectivity: 0.5,
iridescence: 0.4,
thickness: 0.05,
specularIntensity: 1,
metalness: 0.3,
ior: 2,
envMapIntensity: 1,
});
}
}
export class MetalMaterial extends MeshPhysicalMaterial {
constructor() {
super({
metalness: 0.8,
roughness: 0.1,
});
}
}
const voidTagRegex = /<((\S+).*)\/>/g;
const mediaQueryRegex = /@media\(min-width:(\d+)px\)([^@]+)/gm;
const breakpoints = {
'640': 'sm',
'768': 'md',
'1024': 'lg',
'1280': 'xl',
'1536': '2xl',
};
export function parseHtml(text, colorMap) {
text = text
.replaceAll(styleTagRegex, '')
.replaceAll(voidTagRegex, (_, tagContent, tagName) => `<${tagContent}></${tagName}>`);
const element = parseHTML(text, { voidTag: { tags: [] } });
const themeColors = {};
for (const key in colorMap) {
themeColors[key.replaceAll(/([A-Z])/g, (_, char) => `-${char.toLowerCase()}`)] = `$${key}`;
}
const classes = new Map([
[
'material-plastic',
{
panelMaterialClass: PlasticMaterial,
},
],
[
'material-metal',
{
panelMaterialClass: MetalMaterial,
},
],
[
'material-glass',
{
panelMaterialClass: GlassMaterial,
},
],
[
'border-bend',
{
borderBend: 0.5,
},
],
[
'inline-flex',
{
alignSelf: 'flex-start',
},
],
]);
const css = tailwindToCSS({
config: {
theme: {
extend: {
colors: themeColors,
},
},
},
})
.twi(collectClasses(element)
.replaceAll(conditionalRegex, (_, _selector, className) => className)
.replaceAll(spaceXYRegex, (className, negative, dir, value) => {
const multiplier = negative === '-' ? -1 : 1;
switch (dir) {
case 'x':
classes.set(className, { flexDirection: 'row', columnGap: parseFloat(value) * 4 * multiplier });
break;
case 'y':
classes.set(className, { flexDirection: 'column', rowGap: parseFloat(value) * 4 * multiplier });
break;
}
return '';
}), { merge: false, ignoreMediaQueries: false })
.replaceAll(/\\(.)/g, (_, result) => result)
.replaceAll(mediaQueryRegex, (_, breakpoint, content) => {
const prefix = breakpoints[breakpoint];
if (prefix == null) {
return '';
}
content = content.slice(1, -1);
parseCssClassDefinitions(content, (key, properties) => {
const existingProperties = classes.get(key) ?? {};
classes.set(key, { ...existingProperties, [prefix]: { ...existingProperties[prefix], ...properties } });
});
return '';
});
parseCssClassDefinitions(css, (key, properties) => classes.set(key, { ...classes.get(key), ...properties }));
return { classes, element };
}
function parseCssClassDefinitions(css, set) {
let classesResult;
let contentResult;
while ((classesResult = cssClassRegex.exec(css)) != null) {
const [, className, classContent] = classesResult;
const properties = {};
while ((contentResult = cssPropsRegex.exec(classContent)) != null) {
const [, name, value] = contentResult;
properties[kebabToCamelCase(name)] = value;
}
set(className, properties);
}
}
function collectClasses(element) {
let result = '';
if (element instanceof HTMLElement) {
result += ' ' + (element.classNames ?? '');
result += ' ' + (element.attributes.className ?? '');
}
const childrenLength = element.childNodes.length;
for (let i = 0; i < childrenLength; i++) {
result += collectClasses(element.childNodes[i]);
}
return result;
}
export function convertParsedHtml(element, classes, generate, colorMap, componentMap) {
return convertParsedHtmlRecursive(element, classes, 0, generate, colorMap, componentMap);
}
export const conversionPropertyTypes = {
Inheriting: generatedPropertyTypes.Inheriting,
Container: [generatedPropertyTypes.Inheriting, generatedPropertyTypes.Shared, generatedPropertyTypes.Container],
Icon: [generatedPropertyTypes.Inheriting, generatedPropertyTypes.Shared, generatedPropertyTypes.Icon],
Image: [generatedPropertyTypes.Inheriting, generatedPropertyTypes.Shared, generatedPropertyTypes.Image],
Input: [generatedPropertyTypes.Inheriting, generatedPropertyTypes.Shared, generatedPropertyTypes.Input],
Svg: [generatedPropertyTypes.Inheriting, generatedPropertyTypes.Shared, generatedPropertyTypes.Svg],
Text: [generatedPropertyTypes.Inheriting, generatedPropertyTypes.Shared, generatedPropertyTypes.Text],
Video: [generatedPropertyTypes.Inheriting, generatedPropertyTypes.Shared, generatedPropertyTypes.Video],
};
function convertParsedHtmlRecursive(element, classes, index, generate, colorMap, componentMap) {
if (element instanceof HTMLElement && element.tagName?.toLowerCase() === 'svg') {
const { width, height, ...restAttributes } = element.attributes;
const { inheritingProperties, properties, srOnly } = convertMergeSortProperties(false, [...conversionPropertyTypes.Icon, { svgWidth: ['number'], svgHeight: ['number'] }], classes, { svgWidth: toNumber(width) ?? 24, svgHeight: toNumber(height) ?? 24, text: element.toString() }, restAttributes, colorMap);
if (srOnly) {
return undefined;
}
return generate(element, 'Icon', false, { ...inheritingProperties, ...properties }, index);
}
const [{ skipIfEmpty, defaultProperties, children, propertyTypes, renderAs }, custom] = nodeToConversionData(element, componentMap);
if (skipIfEmpty && element.childNodes.length === 0) {
return undefined;
}
if (skipIfEmpty && element.childNodes.length === 1) {
return convertParsedHtmlRecursive(element.childNodes[0], classes, index, generate, colorMap, componentMap);
}
const { inheritingProperties, properties, srOnly } = element instanceof HTMLElement
? convertMergeSortProperties(custom, propertyTypes, classes, defaultProperties, element.attributes, colorMap)
: { inheritingProperties: undefined, properties: undefined, srOnly: false };
if (srOnly) {
return undefined;
}
switch (children) {
case 'none':
return generate(element, renderAs, custom, { ...inheritingProperties, ...properties }, index);
case 'text':
if (!(element instanceof TextNode)) {
return generate(element, renderAs, custom, { ...inheritingProperties, ...properties }, index, element.childNodes
.filter(filterTextNode)
.map((e) => e.text.trim())
.filter((text) => text.length > 0));
}
const text = element.text.trim();
if (text.length === 0) {
return undefined;
}
return generate(element, renderAs, custom, { ...inheritingProperties, ...properties }, index, [text]);
}
let result = generate(element, renderAs, custom, properties ?? {}, index, element.childNodes
.map((node, i) => convertParsedHtmlRecursive(node, classes, i, generate, colorMap, componentMap))
.filter(filterNull));
if (inheritingProperties == null || Object.keys(inheritingProperties).length > 0) {
result = generate(undefined, 'DefaultProperties', false, inheritingProperties ?? {}, index, [result]);
}
return result;
}
function filterTextNode(val) {
return val instanceof TextNode;
}
function nodeToConversionData(element, customComponents) {
if (element instanceof TextNode) {
return [
{
propertyTypes: conversionPropertyTypes.Text,
renderAs: 'Text',
children: 'text',
},
false,
];
}
if (element.rawTagName == null) {
return [
{
skipIfEmpty: true,
propertyTypes: {},
renderAs: 'Fragment',
},
false,
];
}
if (customComponents != null && element.rawTagName in customComponents) {
return [customComponents[element.rawTagName], true];
}
let { children, defaultProperties, renderAs, skipIfEmpty } = htmlDefaults[element.rawTagName.toLowerCase()] ?? {};
if (element.childNodes.length > 0 &&
element.childNodes.every((e) => e instanceof TextNode) &&
element.childNodes.some((e) => e instanceof TextNode && e.text.trim().length > 0)) {
renderAs ??= 'Text';
children ??= 'text';
}
renderAs ??= 'Container';
return [
{
propertyTypes: conversionPropertyTypes[renderAs],
renderAs,
children,
defaultProperties,
skipIfEmpty,
},
false,
];
}
function filterNull(val) {
return val != null;
}
function convertMergeSortProperties(custom, propertyTypes, classes, defaultProperties, attributes, colorMap) {
const [properties, srOnly] = convertHtmlAttributes(custom, propertyTypes, classes, attributes, colorMap);
const result = {
...defaultProperties,
...properties,
};
const inheritingProperties = {};
for (const key in result) {
if (!isInheritingProperty(key)) {
continue;
}
inheritingProperties[key] = result[key];
delete result[key];
}
return {
inheritingProperties,
properties: result,
srOnly,
};
}
const kebebToCamelRegex = /-([a-zA-z])/g;
export function kebabToCamelCase(name) {
return name.replaceAll(kebebToCamelRegex, (_, group) => group.toUpperCase());
}
function convertHtmlAttributes(custom, propertyTypes, classes, { class: _class, className, style, ...rest }, colorMap) {
let srOnly = false;
const result = convertProperties(propertyTypes, rest, colorMap, kebabToCamelCase) ?? {};
if (_class != null) {
if (_class.includes('sr-only')) {
srOnly = true;
}
Object.assign(result, convertTailwind(propertyTypes, classes, _class, colorMap));
}
if (className != null) {
if (className.includes('sr-only')) {
srOnly = true;
}
Object.assign(result, convertTailwind(propertyTypes, classes, className, colorMap));
}
let styles = [];
try {
if (style != null) {
styles = parseInlineCSS(style);
}
}
catch { }
const stylesMap = {};
for (const style of styles) {
if (style.type === 'comment') {
continue;
}
stylesMap[kebabToCamelCase(style.property)] = style.value;
}
Object.assign(result, convertProperties(propertyTypes, stylesMap, colorMap, kebabToCamelCase) ?? {});
if (!custom && !('display' in result) && !('flexDirection' in result)) {
const key = 'flexDirection';
const value = convertProperty(propertyTypes, key, 'column', colorMap);
if (value != null) {
result[key] = value;
}
}
return [result, srOnly];
}
const nonWhitespaceRegex = /\S+/g;
function tailwindToJson(classNames, classes) {
const result = {};
let classNameResult;
while ((classNameResult = nonWhitespaceRegex.exec(classNames)) != null) {
const [className] = classNameResult;
const classesEntry = classes.get(className);
if (classesEntry == null) {
continue;
}
Object.assign(result, classesEntry);
}
return result;
}
const conditionalRegex = /(\S+)\:(\S+)/g;
function convertTailwind(propertyTypes, classes, className, colorMap) {
const properties = {};
const withoutConditionals = className.replaceAll(conditionalRegex, (_, conditional, value) => {
properties[conditional] = {
...properties[conditional],
...tailwindToJson(value, classes),
};
return '';
});
Object.assign(properties, tailwindToJson(withoutConditionals, classes));
return convertProperties(propertyTypes, properties, colorMap) ?? {};
}
export * from './properties.js';