diff --git a/.gitignore b/.gitignore index c77b50d..4a71111 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .DS_Store +*.d.ts *.log coverage/ node_modules/ diff --git a/hastscript-tests.ts b/hastscript-tests.ts deleted file mode 100644 index 863a218..0000000 --- a/hastscript-tests.ts +++ /dev/null @@ -1,18 +0,0 @@ -import h = require('hastscript') -import s = require('hastscript/svg') - -h() // $ExpectType Element -h('.bar', {class: 'bar'}) // $ExpectType Element -h('.bar', 'child text') // $ExpectType Element -h('.bar', ['child text']) // $ExpectType Element -h('.foo', {class: 'bar'}, h('.baz')) // $ExpectType Element -h('.foo', {class: 'bar'}, [h('.baz')]) // $ExpectType Element -h('.bar', {class: 'bar'}, 'child text') // $ExpectType Element -h('.bar', {class: 'bar'}, ['child text']) // $ExpectType Element -h(false) // $ExpectError - -// $ExpectType -s('svg', {xmlns: 'http://www.w3.org/2000/svg', viewbox: '0 0 500 500'}, [ - s('title', 'SVG ` -): Element - -/** - * DSL to create virtual hast trees for HTML or SVG - * - * @param selector Simple CSS selector - * @param properties Map of properties - * @param children (Lists of) child nodes - */ -declare function hastscript( - selector?: string, - properties?: Properties, - children?: string | Node | Array -): Element - -export = hastscript +export function h(): HastRoot +export function h(selector: null | undefined, ...children: HChild[]): HastRoot +export function h( + selector: string, + properties: HProperties, + ...children: HChild[] +): HastElement +export function h(selector: string, ...children: HChild[]): HastElement +export function s(): HastRoot +export function s(selector: null | undefined, ...children: HChild[]): HastRoot +export function s( + selector: string, + properties: HProperties, + ...children: HChild[] +): HastElement +export function s(selector: string, ...children: HChild[]): HastElement +export type HastRoot = import('hast').Root +export type HastElement = import('hast').Element +export type Properties = import('hast').Properties +export type HastChild = HastRoot['children'][number] +export type Info = import('property-information/lib/util/schema').Schema['property'][string] +export type Schema = + | import('property-information/lib/util/schema').Schema + | import('property-information/lib/util/schema').Schema +export type HStyleValue = string | number +export type HStyle = { + [x: string]: HStyleValue +} +export type HPrimitiveValue = string | number | boolean | null | undefined +export type HArrayValue = Array +export type HPropertyValue = HPrimitiveValue | (string | number)[] +export type HProperties = { + [property: string]: + | { + [x: string]: HStyleValue + } + | HPropertyValue +} +export type HPrimitiveChild = string | number | null | undefined +export type HNodeChild = HastChild | HastRoot +export type HArrayChild = Array +export type HChild = + | HPrimitiveChild + | HNodeChild + | (HPrimitiveChild | HNodeChild)[] diff --git a/index.js b/index.js index 79c9d0c..7fb8120 100644 --- a/index.js +++ b/index.js @@ -1,158 +1,228 @@ +/** + * @typedef {import('hast').Root} HastRoot + * @typedef {import('hast').Element} HastElement + * @typedef {import('hast').Properties} Properties + * @typedef {HastRoot['children'][number]} HastChild + * @typedef {import('property-information').html['property'][string]} Info + * @typedef {html|svg} Schema + */ + +/** + * @typedef {string|number} HStyleValue + * @typedef {Object.} HStyle + * @typedef {string|number|boolean|null|undefined} HPrimitiveValue + * @typedef {Array.} HArrayValue + * @typedef {HPrimitiveValue|HArrayValue} HPropertyValue + * @typedef {{[property: string]: HPropertyValue|HStyle}} HProperties + * + * @typedef {string|number|null|undefined} HPrimitiveChild + * @typedef {HastChild|HastRoot} HNodeChild + * @typedef {Array.} HArrayChild + * @typedef {HPrimitiveChild|HNodeChild|HArrayChild} HChild + */ + import {html, svg, find, normalize} from 'property-information' import {parseSelector} from 'hast-util-parse-selector' import {parse as spaces} from 'space-separated-tokens' import {parse as commas} from 'comma-separated-tokens' import {svgCaseSensitiveTagNames} from './svg-case-sensitive-tag-names.js' +var buttonTypes = new Set(['menu', 'submit', 'reset', 'button']) + var own = {}.hasOwnProperty export const h = factory(html, 'div') -h.displayName = 'html' export const s = factory(svg, 'g', svgCaseSensitiveTagNames) -s.displayName = 'svg' +/** + * @param {Schema} schema + * @param {string} defaultTagName + * @param {Array.} [caseSensitive] + */ function factory(schema, defaultTagName, caseSensitive) { var adjust = caseSensitive && createAdjustMap(caseSensitive) - return h - - // Hyperscript compatible DSL for creating virtual hast trees. - function h(selector, properties) { - var node = - selector === undefined || selector === null - ? {type: 'root', children: []} - : parseSelector(selector, defaultTagName) - var name = - selector === undefined || selector === null - ? null - : node.tagName.toLowerCase() - var index = 1 - var property - - // Normalize the name. - if (name !== undefined && name !== null) { - node.tagName = adjust && own.call(adjust, name) ? adjust[name] : name - } - - // Handle props. - if (properties) { - if ( - name === undefined || - name === null || - typeof properties === 'string' || - 'length' in properties || - isNode(name, properties) - ) { - // Nope, it’s something for `children`. - index-- - } else { - for (property in properties) { - if (own.call(properties, property)) { - addProperty(schema, node.properties, property, properties[property]) + const h = + /** + * @type {{ + * (): HastRoot + * (selector: null|undefined, ...children: HChild[]): HastRoot + * (selector: string, properties: HProperties, ...children: HChild[]): HastElement + * (selector: string, ...children: HChild[]): HastElement + * }} + */ + ( + /** + * Hyperscript compatible DSL for creating virtual hast trees. + * + * @param {string|null} [selector] + * @param {HProperties|HChild} [properties] + * @param {...HChild} [children] + */ + function (selector, properties, ...children) { + var index = -1 + /** @type {HastRoot|HastElement} */ + var node + /** @type {string} */ + var name + /** @type {string} */ + var key + + if (selector === undefined || selector === null) { + node = {type: 'root', children: []} + // @ts-ignore Properties are not supported for roots. + children.unshift(properties) + } else { + node = parseSelector(selector, defaultTagName) + // Normalize the name. + name = node.tagName.toLowerCase() + if (adjust && own.call(adjust, name)) name = adjust[name] + node.tagName = name + + // Handle props. + if (isProperties(properties, name)) { + for (key in properties) { + if (own.call(properties, key)) { + addProperty(schema, node.properties, key, properties[key]) + } + } + } else { + children.unshift(properties) } } - } - } - // Handle children. - while (++index < arguments.length) { - addChild(node.children, arguments[index]) - } + // Handle children. + while (++index < children.length) { + addChild(node.children, children[index]) + } - if (name === 'template') { - node.content = {type: 'root', children: node.children} - node.children = [] - } + if (name === 'template') { + node.content = {type: 'root', children: node.children} + node.children = [] + } - return node - } -} + return node + } + ) -function isNode(name, value) { - var type = value.type + return h +} - if (name === 'input' || !type || typeof type !== 'string') { +/** + * @param {HProperties|HChild} value + * @param {string} name + * @returns {value is HProperties} + */ +function isProperties(value, name) { + if ( + value === null || + value === undefined || + typeof value !== 'object' || + Array.isArray(value) + ) { return false } - if (typeof value.children === 'object' && 'length' in value.children) { + if (name === 'input' || !value.type || typeof value.type !== 'string') { return true } - type = type.toLowerCase() + if (Array.isArray(value.children)) { + return false + } if (name === 'button') { - return ( - type !== 'menu' && - type !== 'submit' && - type !== 'reset' && - type !== 'button' - ) + return buttonTypes.has(value.type.toLowerCase()) } - return 'value' in value + return !('value' in value) } +/** + * @param {Schema} schema + * @param {Properties} properties + * @param {string} key + * @param {HStyle|HPropertyValue} value + * @returns {void} + */ function addProperty(schema, properties, key, value) { var info = find(schema, key) - var result = value var index = -1 + /** @type {HPropertyValue} */ + var result + /** @type {Array.} */ var finalResult // Ignore nullish and NaN values. - if ( - result === undefined || - result === null || - (typeof result === 'number' && Number.isNaN(result)) - ) { - return - } + if (value === undefined || value === null) return + + if (typeof value === 'number') { + // Ignore NaN. + if (Number.isNaN(value)) return + result = value + } + // Booleans. + else if (typeof value === 'boolean') { + result = value + } // Handle list values. - if (typeof result === 'string') { + else if (typeof value === 'string') { if (info.spaceSeparated) { - result = spaces(result) + result = spaces(value) } else if (info.commaSeparated) { - result = commas(result) + result = commas(value) } else if (info.commaOrSpaceSeparated) { - result = spaces(commas(result).join(' ')) + result = spaces(commas(value).join(' ')) + } else { + result = parsePrimitive(info, info.property, value) } + } else if (Array.isArray(value)) { + result = value.concat() + } else { + result = info.property === 'style' ? style(value) : String(value) } - // Accept `object` on style. - if (info.property === 'style' && typeof result !== 'string') { - result = style(result) - } - - // Class names (which can be added both on the `selector` and here). - if (info.property === 'className' && properties.className) { - result = properties.className.concat(result) - } - - if (typeof result === 'object' && 'length' in result) { + if (Array.isArray(result)) { finalResult = [] + while (++index < result.length) { + // @ts-ignore Assume no booleans in array. finalResult[index] = parsePrimitive(info, info.property, result[index]) } - } else { - finalResult = parsePrimitive(info, info.property, result) + + result = finalResult } - properties[info.property] = finalResult + // Class names (which can be added both on the `selector` and here). + if (info.property === 'className' && Array.isArray(properties.className)) { + // @ts-ignore Assume no booleans in `className`. + result = properties.className.concat(result) + } + + properties[info.property] = result } +/** + * @param {Array.} nodes + * @param {HChild} value + * @returns {void} + */ function addChild(nodes, value) { var index = -1 - if (typeof value === 'string' || typeof value === 'number') { + if (value === undefined || value === null) { + // Empty. + } else if (typeof value === 'string' || typeof value === 'number') { nodes.push({type: 'text', value: String(value)}) - } else if (typeof value === 'object' && 'length' in value) { + } else if (Array.isArray(value)) { while (++index < value.length) { addChild(nodes, value[index]) } } else if (typeof value === 'object' && 'type' in value) { if (value.type === 'root') { + // @ts-ignore it looks like a root, TS… addChild(nodes, value.children) } else { nodes.push(value) @@ -162,32 +232,39 @@ function addChild(nodes, value) { } } -// Parse a single primitives. +/** + * Parse a single primitives. + * + * @param {Info} info + * @param {string} name + * @param {HPrimitiveValue} value + * @returns {HPrimitiveValue} + */ function parsePrimitive(info, name, value) { - var result = value - - if ( - (info.number || info.positiveNumber) && - !Number.isNaN(Number(result)) && - result !== '' - ) { - result = Number(result) - } + if (typeof value === 'string') { + if (info.number && value && !Number.isNaN(Number(value))) { + return Number(value) + } - // Accept `boolean` and `string`. - if ( - (info.boolean || info.overloadedBoolean) && - typeof result === 'string' && - (result === '' || normalize(value) === normalize(name)) - ) { - result = true + if ( + (info.boolean || info.overloadedBoolean) && + (value === '' || normalize(value) === normalize(name)) + ) { + return true + } } - return result + return value } +/** + * @param {HStyle} value + * @returns {string} + */ function style(value) { + /** @type {Array.} */ var result = [] + /** @type {string} */ var key for (key in value) { @@ -199,7 +276,12 @@ function style(value) { return result.join('; ') } +/** + * @param {Array.} values + * @returns {Object.} + */ function createAdjustMap(values) { + /** @type {Object.} */ var result = {} var index = -1 diff --git a/index.test-d.ts b/index.test-d.ts new file mode 100644 index 0000000..e5632aa --- /dev/null +++ b/index.test-d.ts @@ -0,0 +1,39 @@ +import {expectType, expectError} from 'tsd' +import {Root, Element} from 'hast' +import {h, s} from './index.js' + +expectType(h()) +expectType(s()) +expectError(h(true)) +expectType(h(null)) +expectType(h(undefined)) +expectType(h('')) +expectType(s('')) +expectType(h('', null)) +expectType(h('', undefined)) +expectType(h('', 1)) +expectType(h('', 'a')) +expectError(h('', true)) +expectType(h('', [1, 'a', null])) +expectError(h('', [true])) + +expectType(h('', {})) +expectType(h('', {}, [1, 'a', null])) +expectType(h('', {p: 1})) +expectType(h('', {p: null})) +expectType(h('', {p: undefined})) +expectType(h('', {p: true})) +expectType(h('', {p: false})) +expectType(h('', {p: 'a'})) +expectType(h('', {p: [1]})) +expectError(h('', {p: [true]})) +expectType(h('', {p: ['a']})) +expectType(h('', {p: {x: 1}})) // Style +expectError(h('', {p: {x: true}})) + +expectType( + s('svg', {xmlns: 'http://www.w3.org/2000/svg', viewbox: '0 0 500 500'}, [ + s('title', 'SVG `) ``` -This is useful because it allows using *both* `hastscript/html` and -`hastscript/svg`, although in different files. +This is useful because it allows using *both* `html` and `svg`, although in +different files. ## Security @@ -418,6 +419,8 @@ abide by its terms. [u]: https://github.com/syntax-tree/unist-builder +[build-jsx]: https://github.com/wooorm/estree-util-build-jsx + [bublé]: https://github.com/Rich-Harris/buble [babel]: https://github.com/babel/babel diff --git a/script/generate-jsx.js b/script/generate-jsx.js index 7a379ee..ff938d3 100644 --- a/script/generate-jsx.js +++ b/script/generate-jsx.js @@ -12,6 +12,7 @@ fs.writeFileSync( path.join('test', 'jsx-build-jsx.js'), generate( buildJsx( + // @ts-ignore Acorn nodes are assignable to ESTree nodes. Parser.extend(acornJsx()).parse( doc.replace(/'name'/, "'jsx (build-jsx)'"), {sourceType: 'module'} diff --git a/test/core.js b/test/core.js index 2c395fd..c59cff3 100644 --- a/test/core.js +++ b/test/core.js @@ -840,11 +840,12 @@ test('hastscript', function (t) { ) t.deepEqual( + // @ts-ignore runtime. h('foo', {type: 'text/html', children: {bar: 'baz'}}), { type: 'element', tagName: 'foo', - properties: {type: 'text/html', children: {bar: 'baz'}}, + properties: {type: 'text/html', children: '[object Object]'}, children: [] }, 'should *not* allow omitting `properties` when `children` is not set to an array' @@ -944,6 +945,7 @@ test('hastscript', function (t) { t.throws( function () { + // @ts-ignore runtime. h('foo', {}, true) }, /Expected node, nodes, or string, got `true`/, diff --git a/tsconfig.json b/tsconfig.json index 21dda03..faa74db 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,11 +1,15 @@ { + "include": ["*.js", "script/**/*.js", "test/**/*.js"], "compilerOptions": { - "lib": ["es2015"], - "strict": true, - "baseUrl": ".", - "paths": { - "hastscript": ["index.d.ts"], - "hastscript/svg": ["svg.d.ts"] - } + "target": "ES2020", + "lib": ["ES2020"], + "module": "ES2020", + "moduleResolution": "node", + "allowJs": true, + "checkJs": true, + "declaration": true, + "emitDeclarationOnly": true, + "allowSyntheticDefaultImports": true, + "skipLibCheck": true } } diff --git a/tslint.json b/tslint.json deleted file mode 100644 index 70c4494..0000000 --- a/tslint.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "dtslint/dtslint.json", - "rules": { - "semicolon": false, - "whitespace": false - } -}