308 lines
7.5 KiB
JavaScript
308 lines
7.5 KiB
JavaScript
/**
|
||
* @typedef {import('hast').Element} Element
|
||
* @typedef {import('estree').Property} Property
|
||
* @typedef {import('estree-jsx').JSXElement} JsxElement
|
||
* @typedef {import('estree-jsx').JSXSpreadAttribute} JsxSpreadAttribute
|
||
* @typedef {import('estree-jsx').JSXAttribute} JsxAttribute
|
||
* @typedef {import('../state.js').State} State
|
||
*/
|
||
|
||
/**
|
||
* @typedef {Record<string, string>} Style
|
||
*/
|
||
|
||
import {stringify as commas} from 'comma-separated-tokens'
|
||
import {svg, find, hastToReact} from 'property-information'
|
||
import {stringify as spaces} from 'space-separated-tokens'
|
||
import {
|
||
start as identifierStart,
|
||
cont as identifierCont,
|
||
name as identifierName
|
||
} from 'estree-util-is-identifier-name'
|
||
import styleToObject from 'style-to-object'
|
||
|
||
const own = {}.hasOwnProperty
|
||
const cap = /[A-Z]/g
|
||
const dashSomething = /-([a-z])/g
|
||
|
||
/**
|
||
* Turn a hast element into an estree node.
|
||
*
|
||
* @param {Element} node
|
||
* hast node to transform.
|
||
* @param {State} state
|
||
* Info passed around about the current state.
|
||
* @returns {JsxElement}
|
||
* estree expression.
|
||
*/
|
||
// eslint-disable-next-line complexity
|
||
export function element(node, state) {
|
||
const parentSchema = state.schema
|
||
let schema = parentSchema
|
||
const props = node.properties || {}
|
||
|
||
if (parentSchema.space === 'html' && node.tagName.toLowerCase() === 'svg') {
|
||
schema = svg
|
||
state.schema = schema
|
||
}
|
||
|
||
const children = state.all(node)
|
||
|
||
/** @type {Array<JsxAttribute | JsxSpreadAttribute>} */
|
||
const attributes = []
|
||
/** @type {string} */
|
||
let prop
|
||
|
||
for (prop in props) {
|
||
if (own.call(props, prop)) {
|
||
let value = props[prop]
|
||
const info = find(schema, prop)
|
||
/** @type {JsxAttribute['value']} */
|
||
let attributeValue
|
||
|
||
// Ignore nullish and `NaN` values.
|
||
// Ignore `false` and falsey known booleans.
|
||
if (
|
||
value === undefined ||
|
||
value === null ||
|
||
(typeof value === 'number' && Number.isNaN(value)) ||
|
||
value === false ||
|
||
(!value && info.boolean)
|
||
) {
|
||
continue
|
||
}
|
||
|
||
prop =
|
||
state.elementAttributeNameCase === 'react' && info.space
|
||
? hastToReact[info.property] || info.property
|
||
: info.attribute
|
||
|
||
if (Array.isArray(value)) {
|
||
// Accept `array`.
|
||
// Most props are space-separated.
|
||
value = info.commaSeparated ? commas(value) : spaces(value)
|
||
}
|
||
|
||
if (prop === 'style') {
|
||
let styleObject =
|
||
typeof value === 'object'
|
||
? value
|
||
: parseStyle(String(value), node.tagName)
|
||
|
||
if (state.stylePropertyNameCase === 'css') {
|
||
styleObject = transformStyleToCssCasing(styleObject)
|
||
}
|
||
|
||
/** @type {Array<Property>} */
|
||
const cssProperties = []
|
||
/** @type {string} */
|
||
let cssProp
|
||
|
||
for (cssProp in styleObject) {
|
||
// eslint-disable-next-line max-depth
|
||
if (own.call(styleObject, cssProp)) {
|
||
cssProperties.push({
|
||
type: 'Property',
|
||
method: false,
|
||
shorthand: false,
|
||
computed: false,
|
||
key: identifierName(cssProp)
|
||
? {type: 'Identifier', name: cssProp}
|
||
: {type: 'Literal', value: cssProp},
|
||
value: {type: 'Literal', value: String(styleObject[cssProp])},
|
||
kind: 'init'
|
||
})
|
||
}
|
||
}
|
||
|
||
attributeValue = {
|
||
type: 'JSXExpressionContainer',
|
||
expression: {type: 'ObjectExpression', properties: cssProperties}
|
||
}
|
||
} else if (value === true) {
|
||
attributeValue = null
|
||
} else {
|
||
attributeValue = {type: 'Literal', value: String(value)}
|
||
}
|
||
|
||
if (jsxIdentifierName(prop)) {
|
||
attributes.push({
|
||
type: 'JSXAttribute',
|
||
name: {type: 'JSXIdentifier', name: prop},
|
||
value: attributeValue
|
||
})
|
||
} else {
|
||
attributes.push({
|
||
type: 'JSXSpreadAttribute',
|
||
argument: {
|
||
type: 'ObjectExpression',
|
||
properties: [
|
||
{
|
||
type: 'Property',
|
||
method: false,
|
||
shorthand: false,
|
||
computed: false,
|
||
key: {type: 'Literal', value: String(prop)},
|
||
// @ts-expect-error No need to worry about `style` (which has a
|
||
// `JSXExpressionContainer` value) because that’s a valid identifier.
|
||
value: attributeValue || {type: 'Literal', value: true},
|
||
kind: 'init'
|
||
}
|
||
]
|
||
}
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
// Restore parent schema.
|
||
state.schema = parentSchema
|
||
|
||
/** @type {JsxElement} */
|
||
const result = {
|
||
type: 'JSXElement',
|
||
openingElement: {
|
||
type: 'JSXOpeningElement',
|
||
attributes,
|
||
name: state.createJsxElementName(node.tagName),
|
||
selfClosing: children.length === 0
|
||
},
|
||
closingElement:
|
||
children.length > 0
|
||
? {
|
||
type: 'JSXClosingElement',
|
||
name: state.createJsxElementName(node.tagName)
|
||
}
|
||
: null,
|
||
children
|
||
}
|
||
state.inherit(node, result)
|
||
return result
|
||
}
|
||
|
||
/**
|
||
* Parse CSS rules as a declaration.
|
||
*
|
||
* @param {string} value
|
||
* CSS text.
|
||
* @param {string} tagName
|
||
* Element name.
|
||
* @returns {Style}
|
||
* Props.
|
||
*/
|
||
function parseStyle(value, tagName) {
|
||
/** @type {Style} */
|
||
const result = {}
|
||
|
||
try {
|
||
styleToObject(value, iterator)
|
||
} catch (error) {
|
||
const exception = /** @type {Error} */ (error)
|
||
exception.message =
|
||
tagName + '[style]' + exception.message.slice('undefined'.length)
|
||
throw error
|
||
}
|
||
|
||
return result
|
||
|
||
/**
|
||
* Add `name`, as a CSS prop, to `result`.
|
||
*
|
||
* @param {string} name
|
||
* Key.
|
||
* @param {string} value
|
||
* Value.
|
||
* @returns {void}
|
||
* Nothing.
|
||
*/
|
||
function iterator(name, value) {
|
||
let key = name
|
||
|
||
if (key.slice(0, 2) !== '--') {
|
||
// See: <https://alanhogan.com/code/vendor-prefixed-css-property-names-in-javascript>
|
||
if (key.slice(0, 4) === '-ms-') key = 'ms-' + key.slice(4)
|
||
key = key.replace(dashSomething, toCamel)
|
||
}
|
||
|
||
result[key] = value
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Transform a DOM casing style object to a CSS casing style object.
|
||
*
|
||
* @param {Style} domCasing
|
||
* @returns {Style}
|
||
*/
|
||
function transformStyleToCssCasing(domCasing) {
|
||
/** @type {Style} */
|
||
const cssCasing = {}
|
||
/** @type {string} */
|
||
let from
|
||
|
||
for (from in domCasing) {
|
||
if (own.call(domCasing, from)) {
|
||
let to = from.replace(cap, toDash)
|
||
// Handle `ms-xxx` -> `-ms-xxx`.
|
||
if (to.slice(0, 3) === 'ms-') to = '-' + to
|
||
cssCasing[to] = domCasing[from]
|
||
}
|
||
}
|
||
|
||
return cssCasing
|
||
}
|
||
|
||
/**
|
||
* Make `$1` capitalized.
|
||
*
|
||
* @param {string} _
|
||
* Whatever.
|
||
* @param {string} $1
|
||
* Single ASCII alphabetical.
|
||
* @returns {string}
|
||
* Capitalized `$1`.
|
||
*/
|
||
function toCamel(_, $1) {
|
||
return $1.toUpperCase()
|
||
}
|
||
|
||
/**
|
||
* Make `$0` dash cased.
|
||
*
|
||
* @param {string} $0
|
||
* Capitalized ASCII leter.
|
||
* @returns {string}
|
||
* Dash and lower letter.
|
||
*/
|
||
function toDash($0) {
|
||
return '-' + $0.toLowerCase()
|
||
}
|
||
|
||
/**
|
||
* Checks if the given string is a valid identifier name.
|
||
*
|
||
* Allows dashes, so it’s actually JSX identifier names.
|
||
*
|
||
* @param {string} name
|
||
* Whatever.
|
||
* @returns {boolean}
|
||
* Whether `name` is a valid JSX identifier.
|
||
*/
|
||
function jsxIdentifierName(name) {
|
||
let index = -1
|
||
|
||
while (++index < name.length) {
|
||
if (!(index ? cont : identifierStart)(name.charCodeAt(index))) return false
|
||
}
|
||
|
||
// `false` if `name` is empty.
|
||
return index > 0
|
||
|
||
/**
|
||
* @param {number} code
|
||
* @returns {boolean}
|
||
*/
|
||
function cont(code) {
|
||
return identifierCont(code) || code === 45 /* `-` */
|
||
}
|
||
}
|