Skip to content

Commit

Permalink
Add better custom element support by tightening overload detection
Browse files Browse the repository at this point in the history
Tighted the disambiguation for `h(tagName, propsOrChild)`, to see `props` as `child`
only when it can never be properties.
For realworld hast use cases, this pratically adds support for properties with `type`
(`string`) *and* `value` on custom elements.
And removes support for literal nodes (those with a `value`) from being passed by
omitting properties.
You likely don’t need to pass literals as the second argument.
As the standard hast `text` literal can always be passed as just the string.
Or wrap it in an array.
Or pass empty props.

Closes GH-21.
  • Loading branch information
wooorm committed Jan 21, 2024
1 parent 5063431 commit 8a5f97e
Show file tree
Hide file tree
Showing 5 changed files with 103 additions and 120 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ coverage/
node_modules/
test/jsx-*.js
yarn.lock
*.d.ts.map
*.d.ts
!lib/jsx-classic.d.ts
!lib/jsx-automatic.d.ts
62 changes: 37 additions & 25 deletions lib/create-h.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,6 @@ import {parseSelector} from 'hast-util-parse-selector'
import {find, normalize} from 'property-information'
import {parse as spaces} from 'space-separated-tokens'

const buttonTypes = new Set(['button', 'menu', 'reset', 'submit'])

const own = {}.hasOwnProperty

/**
Expand Down Expand Up @@ -104,7 +102,9 @@ export function createH(schema, defaultTagName, caseSensitive) {
}

// Handle props.
if (isProperties(properties, node.tagName)) {
if (isChild(properties)) {
children.unshift(properties)
} else {
/** @type {string} */
let key

Expand All @@ -113,8 +113,6 @@ export function createH(schema, defaultTagName, caseSensitive) {
addProperty(schema, node.properties, key, properties[key])
}
}
} else {
children.unshift(properties)
}
}

Expand All @@ -139,34 +137,48 @@ export function createH(schema, defaultTagName, caseSensitive) {
*
* @param {Child | Properties} value
* Value to check.
* @param {string} name
* Tag name.
* @returns {value is Properties}
* Whether `value` is a properties object.
* @returns {value is Child}
* Whether `value` is definitely a child.
*/
function isProperties(value, name) {
if (
value === null ||
value === undefined ||
typeof value !== 'object' ||
Array.isArray(value)
) {
return false
}

if (name === 'input' || !value.type || typeof value.type !== 'string') {
function isChild(value) {
// Never properties if not an object.
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
return true
}

if ('children' in value && Array.isArray(value.children)) {
return false
// Never node without `type`; that’s the main discriminator.
if (typeof value.type !== 'string') return false

// Slower check: never property value if object or array with
// non-number/strings.
const record = /** @type {Record<string, unknown>} */ (value)
const keys = Object.keys(value)

for (const key of keys) {
const value = record[key]

if (value && typeof value === 'object') {
if (!Array.isArray(value)) return true

const list = /** @type {Array<unknown>} */ (value)

for (const item of list) {
if (typeof item !== 'number' && typeof item !== 'string') {
return true
}
}
}
}

if (name === 'button') {
return buttonTypes.has(value.type.toLowerCase())
// Also see empty `children` as a node.
if ('children' in value && Array.isArray(value.children)) {
return true
}

return !('value' in value)
// Default to properties, someone can always pass an empty object,
// put `data: {}` in a node,
// or wrap it in an array.
return false
}

/**
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
},
"files": [
"lib/",
"index.d.ts.map",
"index.d.ts",
"index.js"
],
Expand Down
158 changes: 63 additions & 95 deletions test/core.js
Original file line number Diff line number Diff line change
Expand Up @@ -742,132 +742,100 @@ test('children', async function (t) {
}
)

await t.test(
'should allow omitting `properties` for a `string`',
async function () {
assert.deepEqual(h('strong', 'foo'), {
type: 'element',
tagName: 'strong',
properties: {},
children: [{type: 'text', value: 'foo'}]
})
}
)

await t.test(
'should allow omitting `properties` for a node',
async function () {
assert.deepEqual(h('strong', h('span', 'foo')), {
type: 'element',
tagName: 'strong',
properties: {},
children: [
{
type: 'element',
tagName: 'span',
properties: {},
children: [{type: 'text', value: 'foo'}]
}
]
})
}
)

await t.test(
'should allow omitting `properties` for an array',
async function () {
assert.deepEqual(h('strong', ['foo', 'bar']), {
type: 'element',
tagName: 'strong',
properties: {},
children: [
{type: 'text', value: 'foo'},
{type: 'text', value: 'bar'}
]
})
}
)

await t.test(
'should *not* allow omitting `properties` for an `input[type=text][value]`, as those are void and clash',
async function () {
assert.deepEqual(h('input', {type: 'text', value: 'foo'}), {
type: 'element',
tagName: 'input',
properties: {type: 'text', value: 'foo'},
children: []
})
}
)
await t.test('should disambiguate non-object as a child', async function () {
assert.deepEqual(h('x', 'y'), {
type: 'element',
tagName: 'x',
properties: {},
children: [{type: 'text', value: 'y'}]
})
})

await t.test(
'should *not* allow omitting `properties` for a `[type]`, without `value` or `children`',
async function () {
assert.deepEqual(h('a', {type: 'text/html'}), {
type: 'element',
tagName: 'a',
properties: {type: 'text/html'},
children: []
})
}
)
await t.test('should disambiguate `array` as a child', async function () {
assert.deepEqual(h('x', ['y']), {
type: 'element',
tagName: 'x',
properties: {},
children: [{type: 'text', value: 'y'}]
})
})

await t.test(
'should *not* allow omitting `properties` when `children` is not set to an array',
'should not disambiguate an object w/o `type` as a child',
async function () {
assert.deepEqual(h('foo', {type: 'text/html', children: {bar: 'baz'}}), {
type: 'element',
tagName: 'foo',
properties: {type: 'text/html', children: '[object Object]'},
children: []
})
assert.deepEqual(
// @ts-expect-error: incorrect properties.
h('x', {
a: 'y',
b: 1,
c: true,
d: ['z'],
e: {f: true}
}),
{
type: 'element',
tagName: 'x',
properties: {
a: 'y',
b: 1,
c: true,
d: ['z'],
e: '[object Object]'
},
children: []
}
)
}
)

await t.test(
'should *not* allow omitting `properties` when a button has a valid type',
'should disambiguate an object w/ a `type` and an array of non-primitives as a child',
async function () {
assert.deepEqual(h('button', {type: 'submit', value: 'Send'}), {
type: 'element',
tagName: 'button',
properties: {type: 'submit', value: 'Send'},
children: []
})
assert.deepEqual(
// @ts-expect-error: unknown node.
h('x', {type: 'y', key: [{value: 1}]}),
{
type: 'element',
tagName: 'x',
properties: {},
children: [{type: 'y', key: [{value: 1}]}]
}
)
}
)

await t.test(
'should *not* allow omitting `properties` when a button has a valid non-lowercase type',
'should not disambiguate an object w/ a `type` and an array of primitives as a child',
async function () {
assert.deepEqual(h('button', {type: 'BUTTON', value: 'Send'}), {
assert.deepEqual(h('x', {type: 'y', key: [1]}), {
type: 'element',
tagName: 'button',
properties: {type: 'BUTTON', value: 'Send'},
tagName: 'x',
properties: {type: 'y', key: [1]},
children: []
})
}
)

await t.test(
'should *not* allow omitting `properties` when a button has a valid type',
'should disambiguate an object w/ a `type` and an `object` as a child',
async function () {
assert.deepEqual(h('button', {type: 'menu', value: 'Send'}), {
assert.deepEqual(h('x', {type: 'y', data: {bar: 'baz'}}), {
type: 'element',
tagName: 'button',
properties: {type: 'menu', value: 'Send'},
children: []
tagName: 'x',
properties: {},
children: [{type: 'y', data: {bar: 'baz'}}]
})
}
)

await t.test(
'should allow omitting `properties` when a button has an invalid type',
'should disambiguate an object w/ a `type` and an empty `children` array is a child',
async function () {
assert.deepEqual(h('button', {type: 'text', value: 'Send'}), {
assert.deepEqual(h('x', {type: 'y', children: []}), {
type: 'element',
tagName: 'button',
tagName: 'x',
properties: {},
children: [{type: 'text', value: 'Send'}]
children: [{type: 'y', children: []}]
})
}
)
Expand Down
1 change: 1 addition & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"checkJs": true,
"customConditions": ["development"],
"declaration": true,
"declarationMap": true,
"emitDeclarationOnly": true,
"exactOptionalPropertyTypes": true,
"jsx": "preserve",
Expand Down

0 comments on commit 8a5f97e

Please sign in to comment.