From dd943777f1c1d1a07598634dc5e5d170a1623f40 Mon Sep 17 00:00:00 2001 From: Titus Date: Mon, 26 Apr 2021 20:41:59 +0200 Subject: [PATCH] Add support for using `h`, `s` as a JSX pragmas Closes GH-15. Reviewed-by: Christian Murphy Reviewed-by: Remco Haszing --- .gitignore | 1 + factory.js | 18 ++++-- package.json | 9 ++- readme.md | 114 ++++++++++++++++++++++++++++++--- build.js => script/build.js | 0 script/generate-jsx.js | 25 ++++++++ test.js => test/core.js | 104 +++++++++++++++++-------------- test/index.js | 7 +++ test/jsx.jsx | 121 ++++++++++++++++++++++++++++++++++++ 9 files changed, 339 insertions(+), 60 deletions(-) rename build.js => script/build.js (100%) create mode 100644 script/generate-jsx.js rename test.js => test/core.js (92%) create mode 100644 test/index.js create mode 100644 test/jsx.jsx diff --git a/.gitignore b/.gitignore index a6a3ec3..f394799 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ .nyc_output/ coverage/ node_modules/ +test/jsx-*.js hastscript.js hastscript.min.js yarn.lock diff --git a/factory.js b/factory.js index 62e4041..cc39c38 100644 --- a/factory.js +++ b/factory.js @@ -17,17 +17,23 @@ function factory(schema, defaultTagName, caseSensitive) { // Hyperscript compatible DSL for creating virtual hast trees. function h(selector, properties) { - var node = parseSelector(selector, defaultTagName) - var name = node.tagName.toLowerCase() + var node = + selector == null + ? {type: 'root', children: []} + : parseSelector(selector, defaultTagName) + var name = selector == null ? null : node.tagName.toLowerCase() var index = 1 var property // Normalize the name. - node.tagName = adjust && own.call(adjust, name) ? adjust[name] : name + if (name != null) { + node.tagName = adjust && own.call(adjust, name) ? adjust[name] : name + } // Handle props. if (properties) { if ( + name == null || typeof properties === 'string' || 'length' in properties || isNode(name, properties) @@ -134,7 +140,11 @@ function addChild(nodes, value) { addChild(nodes, value[index]) } } else if (typeof value === 'object' && 'type' in value) { - nodes.push(value) + if (value.type === 'root') { + addChild(nodes, value.children) + } else { + nodes.push(value) + } } else { throw new Error('Expected node, nodes, or string, got `' + value + '`') } diff --git a/package.json b/package.json index e533d13..6c5a5aa 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,11 @@ "space-separated-tokens": "^1.0.0" }, "devDependencies": { + "@babel/core": "^7.0.0", + "@babel/plugin-syntax-jsx": "^7.0.0", + "@babel/plugin-transform-react-jsx": "^7.0.0", "browserify": "^17.0.0", + "buble": "^0.20.0", "dtslint": "^4.0.0", "nyc": "^15.0.0", "prettier": "^2.0.0", @@ -54,16 +58,17 @@ "svg-tag-names": "^2.0.0", "tape": "^5.0.0", "tinyify": "^3.0.0", + "unist-builder": "^2.0.0", "xo": "^0.35.0" }, "scripts": { - "generate": "node build", + "generate": "node script/generate-jsx && node script/build", "format": "remark . -qfo && prettier . -w --loglevel warn && xo --fix", "build-bundle": "browserify . -s hastscript > hastscript.js", "build-mangle": "browserify . -s hastscript -p tinyify > hastscript.min.js", "build": "npm run build-bundle && npm run build-mangle", "test-api": "node test", - "test-coverage": "nyc --reporter lcov tape test.js", + "test-coverage": "nyc --reporter lcov tape test/index.js", "test-types": "dtslint .", "test": "npm run generate && npm run format && npm run build && npm run test-coverage && npm run test-types" }, diff --git a/readme.md b/readme.md index 97f4d90..044036d 100644 --- a/readme.md +++ b/readme.md @@ -138,11 +138,19 @@ Yields: ## API -### `h(selector?[, properties][, ...children])` +### `h(selector?[, properties][, …children])` -### `s(selector?[, properties][, ...children])` +### `s(selector?[, properties][, …children])` -DSL to create virtual [**hast**][hast] [*trees*][tree] for HTML or SVG. +Create virtual [**hast**][hast] [*trees*][tree] for HTML or SVG. + +##### Signatures + +* `h(): root` +* `h(null[, …children]): root` +* `h(name[, properties][, …children]): element` + +(and the same for `s`). ##### Parameters @@ -150,22 +158,104 @@ DSL to create virtual [**hast**][hast] [*trees*][tree] for HTML or SVG. Simple CSS selector (`string`, optional). Can contain a tag name (`foo`), IDs (`#bar`), and classes (`.baz`). -If there is no tag name in the selector, `h` defaults to a `div` element, -and `s` to a `g` element. +If the selector is a string but there is no tag name in it, `h` defaults to +build a `div` element, and `s` to a `g` element. `selector` is parsed by [`hast-util-parse-selector`][parse-selector]. +When string, builds an [`Element`][element]. +When nullish, builds a [`Root`][root] instead. ###### `properties` Map of properties (`Object.<*>`, optional). +Keys should match either the HTML attribute name, or the DOM property name, but +are case-insensitive. +Cannot be given when building a [`Root`][root]. ###### `children` -(Lists of) child nodes (`string`, `Node`, `Array.`, optional). -When strings are encountered, they are mapped to [`text`][text] nodes. +(Lists of) children (`string`, `number`, `Node`, `Array.`, optional). +When strings or numbers are encountered, they are mapped to [`Text`][text] +nodes. +If [`Root`][root] nodes are given, their children are used instead. ##### Returns -[`Element`][element]. +[`Element`][element] or [`Root`][root]. + +## JSX + +`hastscript` can be used as a pragma for JSX. +The example above can then be written like so, using inline Babel pragmas, so +that SVG can be used too: + +`example-html.jsx`: + +```jsx +/** @jsx h */ +/** @jsxFrag null */ +var h = require('hastscript') + +console.log( +
+ some text + + + deltaecho + +
+) + +console.log( +
+ + + +
+) +``` + +`example-svg.jsx`: + +```jsx +/** @jsx s */ +/** @jsxFrag null */ +var s = require('hastscript/svg') + +console.log( + + SVG `<circle>` element + + +) +``` + +Because JSX does not allow dots (`.`) or number signs (`#`) in tag names, you +have to pass class names and IDs in as attributes. + +Note that you must still import `hastscript` yourself and configure your +JavaScript compiler to use the identifier you assign it to as a pragma (and +pass `null` for fragments). + +For [bublé][], this can be done by setting `jsx: 'h'` and `jsxFragment: 'null'` +(note that `jsxFragment` is currently only available on the API, not the CLI). +Bublé is less ideal because it allows a single pragma. + +For [Babel][], use [`@babel/plugin-transform-react-jsx`][babel-jsx] (in classic +mode), and pass `pragma: 'h'` and `pragmaFrag: 'null'`. +This is less ideal because it allows a single pragma. + +Babel also lets you configure this in a script: + +```jsx +/** @jsx s */ +/** @jsxFrag null */ +var s = require('hastscript/svg') + +console.log() +``` + +This is useful because it allows using *both* `hastscript/html` and +`hastscript/svg`, although in different files. ## Security @@ -317,10 +407,18 @@ abide by its terms. [element]: https://github.com/syntax-tree/hast#element +[root]: https://github.com/syntax-tree/xast#root + [text]: https://github.com/syntax-tree/hast#text [u]: https://github.com/syntax-tree/unist-builder +[bublé]: https://github.com/Rich-Harris/buble + +[babel]: https://github.com/babel/babel + +[babel-jsx]: https://github.com/babel/babel/tree/main/packages/babel-plugin-transform-react-jsx + [parse-selector]: https://github.com/syntax-tree/hast-util-parse-selector [xss]: https://en.wikipedia.org/wiki/Cross-site_scripting diff --git a/build.js b/script/build.js similarity index 100% rename from build.js rename to script/build.js diff --git a/script/generate-jsx.js b/script/generate-jsx.js new file mode 100644 index 0000000..a21d6f4 --- /dev/null +++ b/script/generate-jsx.js @@ -0,0 +1,25 @@ +'use strict' + +var fs = require('fs') +var path = require('path') +var buble = require('buble') +var babel = require('@babel/core') + +var doc = String(fs.readFileSync(path.join('test', 'jsx.jsx'))) + +fs.writeFileSync( + path.join('test', 'jsx-buble.js'), + buble.transform(doc.replace(/'name'/, "'jsx (buble)'"), { + jsx: 'h', + jsxFragment: 'null' + }).code +) + +fs.writeFileSync( + path.join('test', 'jsx-babel.js'), + babel.transform(doc.replace(/'name'/, "'jsx (babel)'"), { + plugins: [ + ['@babel/plugin-transform-react-jsx', {pragma: 'h', pragmaFrag: 'null'}] + ] + }).code +) diff --git a/test.js b/test/core.js similarity index 92% rename from test.js rename to test/core.js index 6d1b617..045c21f 100644 --- a/test.js +++ b/test/core.js @@ -1,8 +1,8 @@ 'use strict' var test = require('tape') -var s = require('./svg') -var h = require('./html') +var s = require('../svg') +var h = require('../html') test('hastscript', function (t) { t.equal(typeof h, 'function', 'should expose a function') @@ -10,13 +10,19 @@ test('hastscript', function (t) { t.test('selector', function (t) { t.deepEqual( h(), + {type: 'root', children: []}, + 'should create a `root` node without arguments' + ) + + t.deepEqual( + h(''), { type: 'element', tagName: 'div', properties: {}, children: [] }, - 'should create a `div` element without arguments' + 'should create a `div` element w/ an empty string name' ) t.deepEqual( @@ -113,7 +119,7 @@ test('hastscript', function (t) { t.test('properties', function (t) { t.test('known property names', function (t) { t.deepEqual( - h(null, {className: 'foo'}), + h('', {className: 'foo'}), { type: 'element', tagName: 'div', @@ -124,7 +130,7 @@ test('hastscript', function (t) { ) t.deepEqual( - h(null, {class: 'foo'}), + h('', {class: 'foo'}), { type: 'element', tagName: 'div', @@ -135,7 +141,7 @@ test('hastscript', function (t) { ) t.deepEqual( - h(null, {CLASS: 'foo'}), + h('', {CLASS: 'foo'}), { type: 'element', tagName: 'div', @@ -146,7 +152,7 @@ test('hastscript', function (t) { ) t.deepEqual( - h(null, {'class-name': 'foo'}), + h('', {'class-name': 'foo'}), { type: 'element', tagName: 'div', @@ -161,7 +167,7 @@ test('hastscript', function (t) { t.test('unknown property names', function (t) { t.deepEqual( - h(null, {allowbigscreen: true}), + h('', {allowbigscreen: true}), { type: 'element', tagName: 'div', @@ -172,7 +178,7 @@ test('hastscript', function (t) { ) t.deepEqual( - h(null, {allowBigScreen: true}), + h('', {allowBigScreen: true}), { type: 'element', tagName: 'div', @@ -183,7 +189,7 @@ test('hastscript', function (t) { ) t.deepEqual( - h(null, {'allow_big-screen': true}), + h('', {'allow_big-screen': true}), { type: 'element', tagName: 'div', @@ -198,7 +204,7 @@ test('hastscript', function (t) { t.test('other namespaces', function (t) { t.deepEqual( - h(null, {'aria-valuenow': 1}), + h('', {'aria-valuenow': 1}), { type: 'element', tagName: 'div', @@ -209,7 +215,7 @@ test('hastscript', function (t) { ) t.deepEqual( - h(null, {ariaValueNow: 1}), + h('', {ariaValueNow: 1}), { type: 'element', tagName: 'div', @@ -220,7 +226,7 @@ test('hastscript', function (t) { ) t.deepEqual( - s(null, {'color-interpolation-filters': 'sRGB'}), + s('', {'color-interpolation-filters': 'sRGB'}), { type: 'element', tagName: 'g', @@ -231,7 +237,7 @@ test('hastscript', function (t) { ) t.deepEqual( - s(null, {colorInterpolationFilters: 'sRGB'}), + s('', {colorInterpolationFilters: 'sRGB'}), { type: 'element', tagName: 'g', @@ -242,7 +248,7 @@ test('hastscript', function (t) { ) t.deepEqual( - s(null, {'xml:space': 'preserve'}), + s('', {'xml:space': 'preserve'}), { type: 'element', tagName: 'g', @@ -253,7 +259,7 @@ test('hastscript', function (t) { ) t.deepEqual( - s(null, {xmlSpace: 'preserve'}), + s('', {xmlSpace: 'preserve'}), { type: 'element', tagName: 'g', @@ -264,7 +270,7 @@ test('hastscript', function (t) { ) t.deepEqual( - s(null, {'xmlns:xlink': 'http://www.w3.org/1999/xlink'}), + s('', {'xmlns:xlink': 'http://www.w3.org/1999/xlink'}), { type: 'element', tagName: 'g', @@ -275,7 +281,7 @@ test('hastscript', function (t) { ) t.deepEqual( - s(null, {xmlnsXLink: 'http://www.w3.org/1999/xlink'}), + s('', {xmlnsXLink: 'http://www.w3.org/1999/xlink'}), { type: 'element', tagName: 'g', @@ -286,7 +292,7 @@ test('hastscript', function (t) { ) t.deepEqual( - s(null, {'xlink:arcrole': 'http://www.example.com'}), + s('', {'xlink:arcrole': 'http://www.example.com'}), { type: 'element', tagName: 'g', @@ -297,7 +303,7 @@ test('hastscript', function (t) { ) t.deepEqual( - s(null, {xLinkArcRole: 'http://www.example.com'}), + s('', {xLinkArcRole: 'http://www.example.com'}), { type: 'element', tagName: 'g', @@ -312,7 +318,7 @@ test('hastscript', function (t) { t.test('data property names', function (t) { t.deepEqual( - h(null, {'data-foo': true}), + h('', {'data-foo': true}), { type: 'element', tagName: 'div', @@ -323,7 +329,7 @@ test('hastscript', function (t) { ) t.deepEqual( - h(null, {'data-123': true}), + h('', {'data-123': true}), { type: 'element', tagName: 'div', @@ -334,7 +340,7 @@ test('hastscript', function (t) { ) t.deepEqual( - h(null, {dataFooBar: true}), + h('', {dataFooBar: true}), { type: 'element', tagName: 'div', @@ -345,7 +351,7 @@ test('hastscript', function (t) { ) t.deepEqual( - h(null, {data123: true}), + h('', {data123: true}), { type: 'element', tagName: 'div', @@ -356,7 +362,7 @@ test('hastscript', function (t) { ) t.deepEqual( - h(null, {'data-foo.bar': true}), + h('', {'data-foo.bar': true}), { type: 'element', tagName: 'div', @@ -367,7 +373,7 @@ test('hastscript', function (t) { ) t.deepEqual( - h(null, {'dataFoo.bar': true}), + h('', {'dataFoo.bar': true}), { type: 'element', tagName: 'div', @@ -378,7 +384,7 @@ test('hastscript', function (t) { ) t.deepEqual( - h(null, {'data-foo!bar': true}), + h('', {'data-foo!bar': true}), { type: 'element', tagName: 'div', @@ -389,7 +395,7 @@ test('hastscript', function (t) { ) t.deepEqual( - h(null, {'dataFoo!bar': true}), + h('', {'dataFoo!bar': true}), { type: 'element', tagName: 'div', @@ -404,7 +410,7 @@ test('hastscript', function (t) { t.test('unknown property values', function (t) { t.deepEqual( - h(null, {foo: 'bar'}), + h('', {foo: 'bar'}), { type: 'element', tagName: 'div', @@ -415,7 +421,7 @@ test('hastscript', function (t) { ) t.deepEqual( - h(null, {foo: 3}), + h('', {foo: 3}), { type: 'element', tagName: 'div', @@ -426,7 +432,7 @@ test('hastscript', function (t) { ) t.deepEqual( - h(null, {foo: true}), + h('', {foo: true}), { type: 'element', tagName: 'div', @@ -437,7 +443,7 @@ test('hastscript', function (t) { ) t.deepEqual( - h(null, {list: ['bar', 'baz']}), + h('', {list: ['bar', 'baz']}), { type: 'element', tagName: 'div', @@ -448,7 +454,7 @@ test('hastscript', function (t) { ) t.deepEqual( - h(null, {foo: null}), + h('', {foo: null}), { type: 'element', tagName: 'div', @@ -459,7 +465,7 @@ test('hastscript', function (t) { ) t.deepEqual( - h(null, {foo: undefined}), + h('', {foo: undefined}), { type: 'element', tagName: 'div', @@ -470,7 +476,7 @@ test('hastscript', function (t) { ) t.deepEqual( - h(null, {foo: NaN}), + h('', {foo: NaN}), { type: 'element', tagName: 'div', @@ -485,7 +491,7 @@ test('hastscript', function (t) { t.test('known booleans', function (t) { t.deepEqual( - h(null, {allowFullScreen: ''}), + h('', {allowFullScreen: ''}), { type: 'element', tagName: 'div', @@ -496,7 +502,7 @@ test('hastscript', function (t) { ) t.deepEqual( - h(null, {allowFullScreen: 'yup'}), + h('', {allowFullScreen: 'yup'}), { type: 'element', tagName: 'div', @@ -522,7 +528,7 @@ test('hastscript', function (t) { t.test('known overloaded booleans', function (t) { t.deepEqual( - h(null, {download: ''}), + h('', {download: ''}), { type: 'element', tagName: 'div', @@ -533,7 +539,7 @@ test('hastscript', function (t) { ) t.deepEqual( - h(null, {download: 'downLOAD'}), + h('', {download: 'downLOAD'}), { type: 'element', tagName: 'div', @@ -544,7 +550,7 @@ test('hastscript', function (t) { ) t.deepEqual( - h(null, {download: 'example.ogg'}), + h('', {download: 'example.ogg'}), { type: 'element', tagName: 'div', @@ -596,7 +602,7 @@ test('hastscript', function (t) { t.test('known lists', function (t) { t.deepEqual( - h(null, {class: 'foo bar baz'}), + h('', {class: 'foo bar baz'}), { type: 'element', tagName: 'div', @@ -633,7 +639,7 @@ test('hastscript', function (t) { t.test('style', function (t) { t.deepEqual( - h(null, {style: {color: 'red', '-webkit-border-radius': '3px'}}), + h('', {style: {color: 'red', '-webkit-border-radius': '3px'}}), { type: 'element', tagName: 'div', @@ -646,7 +652,7 @@ test('hastscript', function (t) { ) t.deepEqual( - h(null, {style: 'color:/*red*/purple; -webkit-border-radius: 3px'}), + h('', {style: 'color:/*red*/purple; -webkit-border-radius: 3px'}), { type: 'element', tagName: 'div', @@ -1011,13 +1017,19 @@ test('hastscript', function (t) { t.test('svg', function (t) { t.deepEqual( s(), + {type: 'root', children: []}, + 'should create a `root` node without arguments' + ) + + t.deepEqual( + s(''), { type: 'element', tagName: 'g', properties: {}, children: [] }, - 'should create a `g` element without arguments' + 'should create a `g` element w/ an empty string name' ) t.deepEqual( @@ -1132,7 +1144,7 @@ test('hastscript', function (t) { t.test('tag names', function (t) { t.deepEqual( - h(null, [h('DIV'), h('dIv'), h('div')]), + h('', [h('DIV'), h('dIv'), h('div')]), { type: 'element', tagName: 'div', @@ -1147,7 +1159,7 @@ test('hastscript', function (t) { ) t.deepEqual( - s(null, [ + s('', [ s('RECT'), s('rEcT'), s('rect'), diff --git a/test/index.js b/test/index.js new file mode 100644 index 0000000..d39cad7 --- /dev/null +++ b/test/index.js @@ -0,0 +1,7 @@ +'use strict' + +/* eslint-disable import/no-unassigned-import */ +require('./core') +require('./jsx-babel') +require('./jsx-buble') +/* eslint-enable import/no-unassigned-import */ diff --git a/test/jsx.jsx b/test/jsx.jsx new file mode 100644 index 0000000..2dc7856 --- /dev/null +++ b/test/jsx.jsx @@ -0,0 +1,121 @@ +'use strict' + +var test = require('tape') +var u = require('unist-builder') +var h = require('..') + +test('name', function (t) { + t.deepEqual(, h('a'), 'should support a self-closing element') + + t.deepEqual(b, h('a', 'b'), 'should support a value as a child') + + var A = 'a' + + t.deepEqual(, h(A), 'should support an uppercase tag name') + + t.deepEqual( + {1 + 1}, + h('a', '2'), + 'should support expressions as children' + ) + + t.deepEqual(<>, u('root', []), 'should support a fragment') + + t.deepEqual( + <>a, + u('root', [u('text', 'a')]), + 'should support a fragment with text' + ) + + t.deepEqual( + <> + + , + u('root', [h('a')]), + 'should support a fragment with an element' + ) + + t.deepEqual( + <>{-1}, + u('root', [u('text', '-1')]), + 'should support a fragment with an expression' + ) + + var com = {acme: {a: 'A', b: 'B'}} + + t.deepEqual( + , + h(com.acme.a), + 'should support members as names (`a.b`)' + ) + + t.deepEqual(, h('a', {b: true}), 'should support a boolean attribute') + + t.deepEqual( + , + h('a', {b: ''}), + 'should support a double quoted attribute' + ) + + t.deepEqual( + , + h('a', {b: '"'}), + 'should support a single quoted attribute' + ) + + t.deepEqual( + , + h('a', {b: 2}), + 'should support expression value attributes' + ) + + var props = {a: 1, b: 2} + + t.deepEqual( + , + h('a', props), + 'should support expression spread attributes' + ) + + t.deepEqual( + + ce + {1 + 1} + , + h('a', [h('b'), 'c', h('d', 'e'), '2']), + 'should support text, elements, and expressions in jsx' + ) + + t.deepEqual( + + <>{1} + , + h('a', '1'), + 'should support a fragment in an element (#1)' + ) + + var dl = [ + ['Firefox', 'A red panda.'], + ['Chrome', 'A chemical element.'] + ] + + t.deepEqual( +
+ {dl.map(([title, definition]) => ( + <> +
{title}
+
{definition}
+ + ))} +
, + h('dl', [ + h('dt', dl[0][0]), + h('dd', dl[0][1]), + h('dt', dl[1][0]), + h('dd', dl[1][1]) + ]), + 'should support a fragment in an element (#2)' + ) + + t.end() +})