├── .gitignore
├── .babelrc
├── demo
├── css-module
│ ├── style.scss
│ ├── global-style.scss
│ ├── index.jsx
│ └── fruits.jsx.html
├── click-me
│ ├── click-me-view.jsx.html
│ ├── index.jsx
│ └── click-me-container.jsx
├── todo-list
│ ├── index.jsx
│ ├── todo-view.jsx.html
│ └── todo-container.jsx
├── import-export
│ ├── index.jsx
│ ├── user.scss
│ ├── user.jsx.html
│ ├── users-view.jsx.html
│ └── users-container.jsx
├── index.html
├── index.js
└── webpack.config.js
├── integration-tests
├── components
│ ├── hello-world.jsx.html
│ ├── props-spreading.jsx.html
│ ├── hello-world.jsx
│ ├── props-spreading.jsx
│ ├── loops.jsx.html
│ ├── loops.jsx
│ ├── conditionals.jsx.html
│ ├── imports.jsx
│ ├── imports.jsx.html
│ ├── conditionals.jsx
│ ├── bindings.jsx.html
│ └── bindings.jsx
└── all.spec.js
├── .travis.yml
├── lib
├── validate-ast
│ ├── index.spec.js
│ ├── rules
│ │ ├── root-elements.js
│ │ ├── single-component-child.js
│ │ ├── no-component-nesting.js
│ │ ├── single-control-child.js
│ │ ├── no-imports-in-components.js
│ │ ├── single-default-component.js
│ │ ├── style-imports-nesting.js
│ │ ├── style-imports-attributes.js
│ │ ├── loop-attributes.js
│ │ ├── no-control-merge.js
│ │ ├── imports-relation-types.js
│ │ ├── components-attributes.js
│ │ ├── loop-attributes.spec.js
│ │ ├── style-imports-nesting.spec.js
│ │ ├── root-elements.spec.js
│ │ ├── no-component-nesting.spec.js
│ │ ├── component-imports-nesting.js
│ │ ├── no-imports-in-components.spec.js
│ │ ├── single-component-child.spec.js
│ │ ├── single-control-child.spec.js
│ │ ├── components-attributes.spec.js
│ │ ├── single-default-component.spec.js
│ │ ├── imports-relation-types.spec.js
│ │ ├── style-imports-attributes.spec.js
│ │ ├── unique-ids.js
│ │ ├── component-imports-attributes.js
│ │ ├── no-control-merge.spec.js
│ │ ├── component-imports-nesting.spec.js
│ │ ├── unique-ids.spec.js
│ │ └── component-imports-attributes.spec.js
│ ├── result.js
│ └── index.js
├── ast-to-react
│ ├── index.js
│ ├── extract-components.js
│ ├── render-components.js
│ ├── extract-components.spec.js
│ ├── extract-imports.js
│ ├── extract-imports.spec.js
│ ├── render-component.js
│ └── render-component.spec.js
├── constants.js
├── loggers.js
├── index.js
├── html-to-ast
│ ├── index.spec.js
│ └── index.js
└── attribute-conversion.js
├── loader
└── index.js
├── scripts
├── clean.js
└── prepare-release.js
├── LICENSE
├── package.json
└── README.md
/.gitignore:
--------------------------------------------------------------------------------
1 | npm-debug.log*
2 | dist
3 | node_modules
4 |
--------------------------------------------------------------------------------
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [ "react", "es2015" ],
3 | "plugins": [ "transform-object-rest-spread" ]
4 | }
5 |
--------------------------------------------------------------------------------
/demo/css-module/style.scss:
--------------------------------------------------------------------------------
1 | .fruit-list {
2 | margin: 0;
3 | }
4 |
5 | .item {
6 | font-style: italic;
7 | }
8 |
--------------------------------------------------------------------------------
/integration-tests/components/hello-world.jsx.html:
--------------------------------------------------------------------------------
1 |
2 | Hello World
3 |
4 |
--------------------------------------------------------------------------------
/integration-tests/components/props-spreading.jsx.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | Clicked {{ props.clicks }} time(s)
4 |
5 |
6 |
--------------------------------------------------------------------------------
/demo/click-me/click-me-view.jsx.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | Clicked {{ props.clicks }} time(s)
4 |
5 |
6 |
--------------------------------------------------------------------------------
/demo/css-module/global-style.scss:
--------------------------------------------------------------------------------
1 | body {
2 | background-color: #eee;
3 | font-family: Helvetica, Arial, sans-serif;
4 | color: #333;
5 | }
6 |
7 | :global(#app) {
8 | margin: 0 auto;
9 | }
10 |
--------------------------------------------------------------------------------
/demo/css-module/index.jsx:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | import React from 'react';
4 | import ReactDOM from 'react-dom';
5 | import Fruits from './fruits';
6 |
7 | ReactDOM.render( , document.getElementById('app'));
8 |
--------------------------------------------------------------------------------
/demo/todo-list/index.jsx:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | import React from 'react';
4 | import ReactDOM from 'react-dom';
5 | import Todo from './todo-container';
6 |
7 | ReactDOM.render( , document.getElementById('app'));
8 |
--------------------------------------------------------------------------------
/demo/import-export/index.jsx:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | import React from 'react';
4 | import ReactDOM from 'react-dom';
5 | import Users from './users-container';
6 |
7 | ReactDOM.render( , document.getElementById('app'));
8 |
--------------------------------------------------------------------------------
/integration-tests/components/hello-world.jsx:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | import React from 'react';
4 |
5 | export default function(props) {
6 | return (
7 |
8 | Hello World
9 |
10 | );
11 | }
12 |
--------------------------------------------------------------------------------
/demo/click-me/index.jsx:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | import React from 'react';
4 | import ReactDOM from 'react-dom';
5 | import ClickMe from './click-me-container';
6 |
7 | ReactDOM.render( , document.getElementById('app'));
8 |
--------------------------------------------------------------------------------
/demo/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Demo react-pure-html-component-loader
6 |
7 |
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/integration-tests/components/props-spreading.jsx:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | import React from 'react';
4 |
5 | export default function(props) {
6 | return (
7 |
8 | Clicked { props.clicks } time(s)
9 |
10 | );
11 | }
12 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | node_js:
3 | - "6.9.4"
4 | cache:
5 | directories:
6 | - node_modules
7 | install:
8 | - npm install
9 | env:
10 | - TEST_TYPE=lint
11 | - TEST_TYPE=unit-test
12 | - TEST_TYPE=integration-test
13 | script:
14 | - npm run $TEST_TYPE
15 |
--------------------------------------------------------------------------------
/demo/import-export/user.scss:
--------------------------------------------------------------------------------
1 | .user {
2 | display: flex;
3 | flex-direction: row;
4 | align-items: center;
5 | padding: 0.5rem 0.75rem;
6 |
7 | .image {
8 | border-radius: 50%;
9 | margin-right: 1rem;
10 | }
11 |
12 | .name {
13 | font-family: Helvetica;
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/lib/validate-ast/index.spec.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const glob = require('glob');
4 | const path = require('path');
5 |
6 | const rules = glob.sync(path.join(__dirname, 'rules', '**', '*.spec.js'))
7 | .map(p => `./${path.relative(__dirname, p)}`);
8 |
9 | describe('validate-ast', function() {
10 | rules.forEach(test => require(test)());
11 | });
12 |
--------------------------------------------------------------------------------
/demo/import-export/user.jsx.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
9 |
10 | {{ `${props.firstname} ${props.lastname}` }}
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/demo/import-export/users-view.jsx.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/integration-tests/components/loops.jsx.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | {{ user.name }}
6 |
7 |
8 |
9 |
10 |
11 |
12 | {{ user.name }}
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/integration-tests/components/loops.jsx:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | import React from 'react';
4 |
5 | export default function(props) {
6 | return (
7 |
8 | { props.users.map(user => (
9 |
10 | { user.name }
11 |
12 | )) }
13 | { (props.users) && (
14 | props.users.map(user => (
15 |
16 | { user.name }
17 |
18 | ))
19 | ) }
20 |
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/integration-tests/components/conditionals.jsx.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | {{ props.user.name }}
5 |
6 |
7 |
8 |
9 | {{ item.name }}
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 | {{ props.user.name }}
18 |
19 |
20 |
--------------------------------------------------------------------------------
/demo/css-module/fruits.jsx.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Apple
7 | Blueberry
8 | Grapefruit
9 | Orange
10 | Pineapple
11 | Strawberry
12 |
13 |
14 |
--------------------------------------------------------------------------------
/integration-tests/components/imports.jsx:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | import React from 'react';
4 | import AComponent from 'path/to/component/a';
5 | import DefaultComponent, { NamedComponentOne, NamedComponentTwo as NamedComponentTwoAlias } from 'path/to/components/a';
6 | import style from './style';
7 | import './global-style';
8 |
9 | export default function(props) {
10 | return (
11 |
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/loader/index.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const htmlToReact = require('../lib');
4 |
5 | function formatMessage(obj) {
6 | if (typeof obj.line !== 'undefined') {
7 | return ` Line ${obj.line}: ${obj.message}`;
8 | }
9 | return ` ${obj.message}`;
10 | }
11 |
12 | module.exports = function(content) {
13 | this.cacheable();
14 |
15 | const res = htmlToReact({ html: content });
16 |
17 | res.errors.forEach(err => {
18 | this.emitError(formatMessage(err));
19 | });
20 |
21 | res.warnings.forEach(warn => {
22 | this.emitWarning(formatMessage(warn));
23 | });
24 |
25 | return res.reactStr;
26 | };
27 |
--------------------------------------------------------------------------------
/demo/todo-list/todo-view.jsx.html:
--------------------------------------------------------------------------------
1 |
2 | {{ props.value }}
3 |
4 |
5 |
6 |
7 |
15 |
16 |
17 |
21 |
22 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/lib/validate-ast/rules/root-elements.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const constants = require('../../constants');
4 | const result = require('../result');
5 |
6 | module.exports = function(ast) {
7 | const mapper = node => {
8 | if (node.name !== constants.tags.COMPONENT
9 | && node.name !== constants.tags.IMPORT) {
10 | return result.withError(
11 | `Only '<${constants.tags.COMPONENT}>' and '<${constants.tags.IMPORT}>'`
12 | + ` are allowed as root elements`,
13 | node.meta.line
14 | );
15 | }
16 | return result.empty();
17 | };
18 |
19 | return ast.map(mapper)
20 | .reduce(result.reducer, result.empty());
21 | };
22 |
--------------------------------------------------------------------------------
/scripts/clean.js:
--------------------------------------------------------------------------------
1 | /* eslint no-console: "off" */
2 | 'use strict';
3 |
4 | const del = require('del');
5 | const path = require('path');
6 |
7 | function absPath() {
8 | return path.join(__dirname, '..', ...arguments);
9 | }
10 |
11 | const filesToDelete = [ absPath('dist') ];
12 |
13 | del(filesToDelete)
14 | .then(files => {
15 | if (files.length > 0) {
16 | files = files.map(file => path.relative(absPath(''), file));
17 | console.log(`Deleted paths:\n\n ${files.join('\n ')}\n`);
18 | } else {
19 | console.log('Already clean');
20 | }
21 | })
22 | .catch(err => {
23 | console.error(err);
24 | process.exit(1);
25 | });
26 |
--------------------------------------------------------------------------------
/lib/validate-ast/rules/single-component-child.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const constants = require('../../constants');
4 | const result = require('../result');
5 |
6 | function singleChild(node) {
7 | if (node.children.length === 1) {
8 | return result.empty();
9 | }
10 |
11 | return result.withError(
12 | `A '<${constants.tags.COMPONENT}>' must have exactly one child, found`
13 | + ` ${node.children.length}`,
14 | node.meta.line
15 | );
16 | }
17 |
18 | module.exports = function(ast) {
19 | return ast
20 | .filter(node => node.name === constants.tags.COMPONENT)
21 | .map(singleChild)
22 | .reduce(result.reducer, result.empty());
23 | };
24 |
--------------------------------------------------------------------------------
/integration-tests/components/imports.jsx.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
19 |
20 |
--------------------------------------------------------------------------------
/integration-tests/components/conditionals.jsx:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | import React from 'react';
4 |
5 | export function ConditionalFist(props) {
6 | return (
7 | (props.user) && (
8 |
9 | { props.user.name }
10 |
11 | )
12 | );
13 | }
14 | ConditionalFist.displayName = 'ConditionalFist';
15 |
16 | export default function(props) {
17 | return (
18 |
19 | { (props.user) && (
20 |
21 | { props.user.name }
22 |
23 | ) }
24 | { props.array.map(item => (
25 | (item.isValid) && (
26 |
27 | { item.name }
28 |
29 | )
30 | )) }
31 |
32 | );
33 | }
34 |
--------------------------------------------------------------------------------
/demo/import-export/users-container.jsx:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | import React, { Component } from 'react';
4 | import axios from 'axios';
5 |
6 | import UsersView from './users-view';
7 |
8 | export default class UsersContainer extends Component {
9 | constructor(props) {
10 | super(props);
11 |
12 | this.state = { users: [] };
13 | }
14 |
15 | componentDidMount() {
16 | this.fetchUsers();
17 | }
18 |
19 | fetchUsers() {
20 | axios('https://randomuser.me/api?results=100&seed=react')
21 | .then(res => this.setState({ users: res.data.results }));
22 | }
23 |
24 | render() {
25 | return (
26 |
27 | );
28 | }
29 | }
30 |
31 | UsersContainer.displayName = 'UsersContainer';
32 |
--------------------------------------------------------------------------------
/lib/validate-ast/rules/no-component-nesting.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const constants = require('../../constants');
4 | const result = require('../result');
5 |
6 | function noComponentNesting(children) {
7 | const mapper = child => {
8 | if (child.name === constants.tags.COMPONENT) {
9 | return result.withError(
10 | `A '<${constants.tags.COMPONENT}>' cannot be nested`,
11 | child.meta.line
12 | );
13 | }
14 | return noComponentNesting(child.children);
15 | };
16 |
17 | return children.map(mapper)
18 | .reduce(result.reducer, result.empty());
19 | }
20 |
21 | module.exports = function(ast) {
22 | return ast.map(root => noComponentNesting(root.children))
23 | .reduce(result.reducer, result.empty());
24 | };
25 |
--------------------------------------------------------------------------------
/demo/click-me/click-me-container.jsx:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | import React, { Component } from 'react';
4 |
5 | import ClickMeView from './click-me-view';
6 |
7 | export default class ClickMeContainer extends Component {
8 | constructor(props) {
9 | super(props);
10 |
11 | this.state = { clicks: 0 };
12 | this.buttonProps = { onMouseDown: this.handleMouseDown.bind(this) };
13 | }
14 |
15 | handleMouseDown(e) {
16 | e.preventDefault();
17 | this.setState({ clicks: this.state.clicks + 1 });
18 | }
19 |
20 | render() {
21 | return (
22 |
26 | );
27 | }
28 | }
29 |
30 | ClickMeContainer.displayName = 'ClickMeContainer';
31 |
--------------------------------------------------------------------------------
/lib/validate-ast/rules/single-control-child.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const constants = require('../../constants');
4 | const result = require('../result');
5 |
6 | function singleControlChild(node) {
7 | let res = result.empty();
8 |
9 | if (node.name === constants.tags.CONTROLS && node.children.length !== 1) {
10 | res = result.withError(
11 | `A '<${constants.tags.CONTROLS}>' must have exactly one child, found`
12 | + ` ${node.children.length}`,
13 | node.meta.line
14 | );
15 | }
16 |
17 | return node.children
18 | .map(singleControlChild)
19 | .reduce(result.reducer, res);
20 | }
21 |
22 | module.exports = function(ast) {
23 | return ast
24 | .map(singleControlChild)
25 | .reduce(result.reducer, result.empty());
26 | };
27 |
--------------------------------------------------------------------------------
/lib/validate-ast/rules/no-imports-in-components.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const constants = require('../../constants');
4 | const result = require('../result');
5 |
6 | function noImportInComponents(node) {
7 | const mapper = child => {
8 | if (child.name === constants.tags.IMPORT) {
9 | return result.withError(
10 | 'Imports should be at the top level of the file',
11 | child.meta.line
12 | );
13 | }
14 | return noImportInComponents(child);
15 | };
16 |
17 | return node.children
18 | .map(mapper)
19 | .reduce(result.reducer, result.empty());
20 | }
21 |
22 | module.exports = function(ast) {
23 | return ast
24 | .filter(node => node.name === constants.tags.COMPONENT)
25 | .map(noImportInComponents)
26 | .reduce(result.reducer, result.empty());
27 | };
28 |
--------------------------------------------------------------------------------
/lib/validate-ast/rules/single-default-component.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const constants = require('../../constants');
4 | const result = require('../result');
5 |
6 | const componentsAttrs = constants.attributes.components;
7 |
8 | function onlyDefault(node) {
9 | return node.name === constants.tags.COMPONENT
10 | && node.attrs.hasOwnProperty(componentsAttrs.DEFAULT);
11 | }
12 |
13 | module.exports = function(ast) {
14 | const defaultComponents = ast.filter(onlyDefault);
15 |
16 | if (defaultComponents.length > 1) {
17 | const mapper = node => result.withError(
18 | `Only one default component is allowed`,
19 | node.meta.line
20 | );
21 |
22 | return defaultComponents
23 | .map(mapper)
24 | .reduce(result.reducer, result.empty());
25 | }
26 |
27 | return result.empty();
28 | };
29 |
--------------------------------------------------------------------------------
/lib/validate-ast/rules/style-imports-nesting.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const constants = require('../../constants');
4 | const result = require('../result');
5 |
6 | const importAttrs = constants.attributes.import;
7 |
8 | function importSubTree(node) {
9 | if (node.children.length > 0) {
10 | return result.withError(
11 | `A style import cannot have children, found ${node.children.length}`,
12 | node.meta.line
13 | );
14 | }
15 |
16 | return result.empty();
17 | }
18 |
19 | function importsNesting(ast) {
20 | return ast
21 | .filter(node => (
22 | node.name === constants.tags.IMPORT
23 | && node.attrs[importAttrs.TYPE] === importAttrs.types.STYLESHEET
24 | ))
25 | .map(importSubTree)
26 | .reduce(result.reducer, result.empty());
27 | }
28 |
29 | module.exports = importsNesting;
30 |
--------------------------------------------------------------------------------
/lib/ast-to-react/index.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const extractImports = require('./extract-imports');
4 | const extractComponents = require('./extract-components');
5 | const renderComponents = require('./render-components');
6 |
7 | function astToReact(ast) {
8 | const imports = extractImports(ast.roots);
9 | const components = extractComponents(ast.roots);
10 | const renderedComponents = renderComponents({
11 | namedNodes: components.namedNodes,
12 | defaultNode: components.defaultNode,
13 | tagToVar: Object.assign({}, imports.tagToVar, components.tagToVar)
14 | });
15 |
16 | let content = `'use strict';\n\nimport React from 'react';\n`;
17 | content = `${content}${imports.rendered}\n`;
18 | content = `${content}${renderedComponents.join('\n')}`;
19 | return content;
20 | }
21 |
22 | module.exports = astToReact;
23 |
--------------------------------------------------------------------------------
/lib/validate-ast/rules/style-imports-attributes.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const constants = require('../../constants');
4 | const result = require('../result');
5 |
6 | const importAttrs = constants.attributes.import;
7 |
8 | function importAttributes(node) {
9 | if (!node.attrs[importAttrs.PATH]) {
10 | return result.withError(
11 | `A style import must have the attribute '${importAttrs.PATH}'`,
12 | node.meta.line
13 | );
14 | }
15 |
16 | return result.empty();
17 | }
18 |
19 | function importsAttributes(ast) {
20 | return ast
21 | .filter(node => (
22 | node.name === constants.tags.IMPORT
23 | && node.attrs[importAttrs.TYPE] === importAttrs.types.STYLESHEET
24 | ))
25 | .map(importAttributes)
26 | .reduce(result.reducer, result.empty());
27 | }
28 |
29 | module.exports = importsAttributes;
30 |
--------------------------------------------------------------------------------
/lib/validate-ast/rules/loop-attributes.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const constants = require('../../constants');
4 | const result = require('../result');
5 |
6 | const controlAttrs = constants.attributes.controls;
7 |
8 | function loopAttributes(node) {
9 | let res = result.empty();
10 |
11 | if (node.name === constants.tags.CONTROLS
12 | && node.attrs.hasOwnProperty(controlAttrs.LOOP_ARRAY)
13 | && !node.attrs[controlAttrs.LOOP_VAR_NAME]) {
14 | res = result.withError(
15 | `A loop must have the attribute '${controlAttrs.LOOP_VAR_NAME}'`,
16 | node.meta.line
17 | );
18 | }
19 |
20 | return node.children
21 | .map(loopAttributes)
22 | .reduce(result.reducer, res);
23 | }
24 |
25 | module.exports = function(ast) {
26 | return ast
27 | .map(loopAttributes)
28 | .reduce(result.reducer, result.empty());
29 | };
30 |
--------------------------------------------------------------------------------
/lib/validate-ast/rules/no-control-merge.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const constants = require('../../constants');
4 | const result = require('../result');
5 |
6 | const controlAttrs = constants.attributes.controls;
7 |
8 | function noControlMerge(node) {
9 | let res = result.empty();
10 |
11 | if (node.name === constants.tags.CONTROLS
12 | && (node.attrs.hasOwnProperty(controlAttrs.LOOP_ARRAY)
13 | === node.attrs.hasOwnProperty(controlAttrs.CONDITIONALS_TEST))) {
14 | res = result.withError(
15 | `A '<${constants.tags.CONTROLS}>' must be either a conditional or a loop`,
16 | node.meta.line
17 | );
18 | }
19 |
20 | return node.children
21 | .map(noControlMerge)
22 | .reduce(result.reducer, res);
23 | }
24 |
25 | module.exports = function(ast) {
26 | return ast
27 | .map(noControlMerge)
28 | .reduce(result.reducer, result.empty());
29 | };
30 |
--------------------------------------------------------------------------------
/lib/validate-ast/rules/imports-relation-types.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const constants = require('../../constants');
4 | const result = require('../result');
5 |
6 | const importAttrs = constants.attributes.import;
7 | const types = Object.keys(importAttrs.types)
8 | .map(k => importAttrs.types[k]);
9 | const typesStr = types.map(t => `'${t}'`).join(', ');
10 |
11 | function hasValidRelationType(node) {
12 | const importType = node.attrs[importAttrs.TYPE];
13 |
14 | if (types.some(type => type === importType)) {
15 | return result.empty();
16 | }
17 |
18 | return result.withError(
19 | `Import type '${importType}' is not valid, should be one of: ${typesStr}`,
20 | node.meta.line
21 | );
22 | }
23 |
24 | module.exports = function(ast) {
25 | return ast
26 | .filter(node => node.name === constants.tags.IMPORT)
27 | .map(hasValidRelationType)
28 | .reduce(result.reducer, result.empty());
29 | };
30 |
--------------------------------------------------------------------------------
/lib/constants.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | module.exports = Object.freeze({
4 | tags: {
5 | IMPORT: 'link',
6 | COMPONENT: 'template',
7 | CONTROLS: 'render'
8 | },
9 |
10 | attributes: {
11 | import: {
12 | TYPE: 'rel',
13 | PATH: 'href',
14 | NAMED: 'name',
15 | ALIAS: 'id',
16 | OBJECT_VALUE: 'id',
17 |
18 | types: {
19 | COMPONENT: 'import',
20 | STYLESHEET: 'stylesheet'
21 | }
22 | },
23 |
24 | controls: {
25 | LOOP_ARRAY: 'for-each',
26 | LOOP_VAR_NAME: 'as',
27 | CONDITIONALS_TEST: 'if'
28 | },
29 |
30 | components: {
31 | ID: 'id',
32 | DEFAULT: 'default'
33 | },
34 |
35 | PROPS_SPREADING: 'use-props',
36 |
37 | bindings: {
38 | PATTERN: /{{\s*((?:(?!(?:{{|}})).)*?)\s*}}/g,
39 | STRICT_PATTERN: /^\s*{{\s*((?:(?!(?:{{|}})).)*?)\s*}}\s*$/,
40 | BOLLEAN_PATTERN: /^\s*(true|false)\s*$/i
41 | }
42 | }
43 | });
44 |
--------------------------------------------------------------------------------
/lib/validate-ast/result.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | function createResult(errors, warnings) {
4 | errors = errors || [];
5 | warnings = warnings || [];
6 |
7 | return {
8 | errors,
9 | warnings,
10 | concat,
11 | build
12 | };
13 | }
14 |
15 | function concat(other) {
16 | return createResult(
17 | this.errors.concat(other.errors),
18 | this.warnings.concat(other.warnings)
19 | );
20 | }
21 |
22 | function build() {
23 | return { errors: this.errors, warnings: this.warnings };
24 | }
25 |
26 | function empty() {
27 | return createResult();
28 | }
29 |
30 | function withError(message, line) {
31 | return createResult([{ message, line }]);
32 | }
33 |
34 | function withWarning(message, line) {
35 | return createResult([], [{ message, line }]);
36 | }
37 |
38 | function reducer(acc, res) {
39 | return acc.concat(res);
40 | }
41 |
42 | module.exports = {
43 | empty,
44 | withError,
45 | withWarning,
46 | reducer
47 | };
48 |
--------------------------------------------------------------------------------
/lib/validate-ast/rules/components-attributes.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const constants = require('../../constants');
4 | const result = require('../result');
5 |
6 | const componentAttrs = constants.attributes.components;
7 |
8 | function componentAttributes(node) {
9 | if (node.attrs[componentAttrs.ID]) {
10 | return result.empty();
11 | }
12 |
13 | if (node.attrs.hasOwnProperty(componentAttrs.DEFAULT)) {
14 | return result.withWarning(
15 | `Concider adding an attribute '${componentAttrs.ID}' to a default`
16 | + ` component for debug purposes`,
17 | node.meta.line
18 | );
19 | }
20 |
21 | return result.withError(
22 | `A named component must have an attribute '${componentAttrs.ID}' to be used`,
23 | node.meta.line
24 | );
25 | }
26 |
27 | module.exports = function(ast) {
28 | return ast
29 | .filter(node => node.name === constants.tags.COMPONENT)
30 | .map(componentAttributes)
31 | .reduce(result.reducer, result.empty());
32 | };
33 |
--------------------------------------------------------------------------------
/demo/index.js:
--------------------------------------------------------------------------------
1 | /* eslint no-console:"off" */
2 | 'use strict';
3 |
4 | const minimistOptions = {
5 | alias: { port: 'p' },
6 | default: { port: 8080 }
7 | };
8 |
9 | const argv = require('minimist')(process.argv.slice(2), minimistOptions);
10 | const path = require('path');
11 | const webpack = require('webpack');
12 | const WebpackDevServer = require('webpack-dev-server');
13 |
14 | const webpackConfig = require('./webpack.config.js');
15 |
16 | let demo = path.join('demo', 'todo-list');
17 | if (argv._.length === 1) {
18 | demo = argv._[0];
19 | }
20 |
21 | const config = webpackConfig({
22 | port: argv.port,
23 | outputDir: path.resolve(),
24 | entry: path.join(process.env.PWD, demo, 'index.jsx'),
25 | html: path.join(__dirname, 'index.html')
26 | });
27 |
28 | const devServerOptions = {
29 | stats: { colors: true }
30 | };
31 |
32 | new WebpackDevServer(webpack(config), devServerOptions)
33 | .listen(argv.port, '0.0.0.0', err => {
34 | if (err) {
35 | console.error(err);
36 | process.exit(1);
37 | }
38 | });
39 |
--------------------------------------------------------------------------------
/lib/loggers.js:
--------------------------------------------------------------------------------
1 | /* eslint no-console: "off" */
2 | 'use strict';
3 |
4 | const NONE = 10;
5 | const LOG = 1;
6 | const WARNING = 2;
7 | const ERROR = 3;
8 |
9 | function log(level) {
10 | return function() {
11 | if (level <= LOG) {
12 | console.log.apply(console, arguments);
13 | }
14 | };
15 | }
16 |
17 | function warn(level) {
18 | return function() {
19 | if (level <= WARNING) {
20 | console.warn.apply(console, arguments);
21 | }
22 | };
23 | }
24 |
25 | function error(level) {
26 | return function() {
27 | if (level <= ERROR) {
28 | console.error.apply(console, arguments);
29 | }
30 | };
31 | }
32 |
33 | function str(obj) {
34 | return JSON.stringify(obj, null, ' ');
35 | }
36 |
37 | module.exports = function(level) {
38 | level = level || NONE;
39 | return {
40 | log: log(level),
41 | warn: warn(level),
42 | error: error(level)
43 | };
44 | };
45 |
46 | module.exports.str = str;
47 | module.exports.levels = {
48 | NONE,
49 | LOG,
50 | WARNING,
51 | ERROR
52 | };
53 |
--------------------------------------------------------------------------------
/demo/todo-list/todo-container.jsx:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | import React, { Component } from 'react';
4 |
5 | import TodoView from './todo-view';
6 |
7 | export default class Todo extends Component {
8 | constructor(props) {
9 | super(props);
10 |
11 | this.state = { todos: [], value: '' };
12 | this.inputProps = { onChange: this.handleValueChanged.bind(this) };
13 | this.formProps = { onSubmit: this.handleAddTodo.bind(this) };
14 | }
15 |
16 | handleValueChanged(e) {
17 | e.preventDefault();
18 | this.setState({ value: e.target.value });
19 | }
20 |
21 | handleAddTodo(e) {
22 | e.preventDefault();
23 |
24 | const { todos, value } = this.state;
25 | this.setState({ todos: todos.concat(value), value: '' });
26 | }
27 |
28 | render() {
29 | const { todos, value } = this.state;
30 | return (
31 |
36 | );
37 | }
38 | }
39 |
40 | Todo.displayName = 'Todo';
41 |
--------------------------------------------------------------------------------
/lib/validate-ast/rules/loop-attributes.spec.js:
--------------------------------------------------------------------------------
1 | /* eslint no-unused-expressions: "off" */
2 | 'use strict';
3 |
4 | const chai = require('chai');
5 | const expect = chai.expect;
6 |
7 | const loopAttributes = require('./loop-attributes');
8 | const result = require('../result');
9 |
10 | module.exports = function() {
11 | describe('loop-attributes', function() {
12 | it(`should accept ''`, function() {
13 | const ast = [{
14 | name: 'render',
15 | attrs: { 'for-each': 'hello', 'as': 'wat' },
16 | children: []
17 | }];
18 | const res = loopAttributes(ast).build();
19 |
20 | expect(res).to.deep.equal(result.empty().build());
21 | });
22 |
23 | it(`should reject missing attribute 'as' in ''`, function() {
24 | const ast = [{
25 | name: 'render',
26 | attrs: { 'for-each': '' },
27 | children: [],
28 | meta: {}
29 | }];
30 | const res = loopAttributes(ast).build();
31 |
32 | expect(res.errors.length).to.equal(1);
33 | });
34 | });
35 | };
36 |
--------------------------------------------------------------------------------
/integration-tests/all.spec.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const glob = require('glob');
4 | const path = require('path');
5 | const fs = require('fs-promise');
6 | const chai = require('chai');
7 | const reactHtmlTemplate = require('../lib');
8 |
9 | const expect = chai.expect;
10 | const pattern = `${__dirname}/components/**/*.jsx.html`;
11 | const components = glob.sync(pattern).map(f => ({
12 | srcPath: f,
13 | expectedPath: f.replace('.html', '')
14 | }));
15 |
16 | describe('E2E tests', function() {
17 | components.forEach(template => {
18 | const name = path.basename(template.srcPath, '.jsx.html');
19 | const contents = [
20 | fs.readFile(template.srcPath, 'utf8'),
21 | fs.readFile(template.expectedPath, 'utf8')
22 | ];
23 | const test = (html, expected) => {
24 | const res = reactHtmlTemplate({ html });
25 | expect(res.reactStr).to.equal(expected);
26 | };
27 |
28 | it(name, function(done) {
29 | Promise.all(contents)
30 | .then(res => test(...res))
31 | .then(done)
32 | .catch(done);
33 | });
34 | });
35 | });
36 |
--------------------------------------------------------------------------------
/lib/validate-ast/rules/style-imports-nesting.spec.js:
--------------------------------------------------------------------------------
1 | /* eslint no-unused-expressions: "off" */
2 | 'use strict';
3 |
4 | const chai = require('chai');
5 | const expect = chai.expect;
6 |
7 | const importsNesting = require('./style-imports-nesting');
8 | const result = require('../result');
9 |
10 | module.exports = function() {
11 | describe('style-imports-nesting', function() {
12 | it('should accept absence of child', function() {
13 | const ast = [{ name: 'link', attrs: { rel: 'stylesheet' }, children: [] }];
14 | const res = importsNesting(ast).build();
15 |
16 | expect(res).to.deep.equal(result.empty().build());
17 | });
18 |
19 | it('should reject any children elements', function() {
20 | const ast = [{
21 | name: 'link',
22 | attrs: { rel: 'stylesheet' },
23 | children: [
24 | { name: 'div', children: [], meta: {} },
25 | { name: 'div', children: [], meta: {} }
26 | ],
27 | meta: {}
28 | }];
29 | const res = importsNesting(ast).build();
30 |
31 | expect(res.errors.length).to.equal(1);
32 | });
33 | });
34 | };
35 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2016 Simon Relet
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/lib/validate-ast/rules/root-elements.spec.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const chai = require('chai');
4 | const expect = chai.expect;
5 |
6 | const rootElements = require('./root-elements');
7 | const result = require('../result');
8 |
9 | module.exports = function() {
10 | describe('root-elements', function() {
11 | it(`should accept '' as root element`, function() {
12 | const ast = [{ name: 'template' }];
13 | const res = rootElements(ast).build();
14 |
15 | expect(res).to.deep.equal(result.empty().build());
16 | });
17 |
18 | it(`should accept ' ' as root element`, function() {
19 | const ast = [{ name: 'link' }];
20 | const res = rootElements(ast).build();
21 |
22 | expect(res).to.deep.equal(result.empty().build());
23 | });
24 |
25 | [ 'div', 'span', 'html', 'body', 'header', 'title', 'wat' ].forEach(elt => {
26 | it(`should reject '<${elt}>' as root element`, function() {
27 | const ast = [{ name: elt, meta: {} }];
28 | const res = rootElements(ast).build();
29 |
30 | expect(res.errors.length).to.equal(1);
31 | });
32 | });
33 | });
34 | };
35 |
--------------------------------------------------------------------------------
/lib/validate-ast/rules/no-component-nesting.spec.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const chai = require('chai');
4 | const expect = chai.expect;
5 |
6 | const noComponentNesting = require('./no-component-nesting');
7 | const result = require('../result');
8 |
9 | module.exports = function() {
10 | describe('no-component-nesting', function() {
11 | it(`should accept ''s as root elements`, function() {
12 | const ast = [
13 | { name: 'template', children: [] },
14 | { name: 'template', children: [] },
15 | { name: 'template', children: [] }
16 | ];
17 | const res = noComponentNesting(ast).build();
18 |
19 | expect(res).to.deep.equal(result.empty().build());
20 | });
21 |
22 | it(`should reject nested ''s`, function() {
23 | const ast = [{
24 | name: 'div',
25 | children: [{
26 | name: 'div',
27 | children: [{
28 | name: 'template',
29 | meta: {}
30 | }]
31 | }]
32 | }];
33 | const res = noComponentNesting(ast).build();
34 |
35 | expect(res.errors.length).to.equal(1);
36 | });
37 | });
38 | };
39 |
--------------------------------------------------------------------------------
/integration-tests/components/bindings.jsx.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 | {{ props.value }}
12 |
13 | {{ props.value }}
14 |
15 | {{ props.value }}
16 | {{props.value}}
17 | {{ props.value + ' ' + otherValue }}
18 | Hello {{ props.person }}
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
--------------------------------------------------------------------------------
/lib/ast-to-react/extract-components.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const pascalCase = require('change-case').pascalCase;
4 |
5 | const constants = require('../constants');
6 | const componentsAttrs = constants.attributes.components;
7 |
8 | function tagToVar(components) {
9 | const reducer = (map, t) => {
10 | const id = t.attrs[componentsAttrs.ID];
11 | if (id) {
12 | map[id] = pascalCase(id);
13 | }
14 |
15 | return map;
16 | };
17 |
18 | return components.reduce(reducer, {});
19 | }
20 |
21 | function getNamedNodes(components) {
22 | return components.filter(t => (
23 | t.attrs[componentsAttrs.ID]
24 | && !t.attrs.hasOwnProperty(componentsAttrs.DEFAULT))
25 | );
26 | }
27 |
28 | function getDefaultNode(templates) {
29 | return templates.filter(t => t.attrs.hasOwnProperty(componentsAttrs.DEFAULT))[0];
30 | }
31 |
32 | function extractComponents(roots) {
33 | const components = roots.filter(node => node.name === constants.tags.COMPONENT);
34 |
35 | return {
36 | tagToVar: tagToVar(components),
37 | namedNodes: getNamedNodes(components),
38 | defaultNode: getDefaultNode(components)
39 | };
40 | }
41 |
42 | module.exports = extractComponents;
43 |
--------------------------------------------------------------------------------
/lib/validate-ast/rules/component-imports-nesting.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const constants = require('../../constants');
4 | const result = require('../result');
5 |
6 | const importAttrs = constants.attributes.import;
7 |
8 | function importSubTree(node) {
9 | const mapper = child => {
10 | if (child.name !== constants.tags.IMPORT) {
11 | return result.withError(
12 | 'Only named component imports are allowed in an component import',
13 | child.meta.line
14 | );
15 | }
16 | if (child.children.length > 0) {
17 | return result.withError(
18 | 'A named component imports should not have children',
19 | child.meta.line
20 | );
21 | }
22 | return result.empty();
23 | };
24 |
25 | return node.children
26 | .map(mapper)
27 | .reduce(result.reducer, result.empty());
28 | }
29 |
30 | function importsNesting(ast) {
31 | return ast
32 | .filter(node => (
33 | node.name === constants.tags.IMPORT
34 | && node.attrs[importAttrs.TYPE] === importAttrs.types.COMPONENT
35 | ))
36 | .map(importSubTree)
37 | .reduce(result.reducer, result.empty());
38 | }
39 |
40 | module.exports = importsNesting;
41 |
--------------------------------------------------------------------------------
/lib/index.js:
--------------------------------------------------------------------------------
1 | /* eslint no-console: "off" */
2 | 'use strict';
3 |
4 | const astToReact = require('./ast-to-react');
5 | const validateAst = require('./validate-ast');
6 | const htmlToAst = require('./html-to-ast');
7 |
8 | /**
9 | * The result object.
10 | *
11 | * @typedef {object} Result
12 | * @property {string} reactStr The React component
13 | */
14 |
15 | /**
16 | * The options object.
17 | *
18 | * @typedef {object} Options
19 | * @property {string} html The HTML
20 | * @property {string} [verbosity=NONE] The verbosity level. One of NONE, LOG,
21 | * WARNING, ERROR
22 | */
23 |
24 | /**
25 | * Compiles an HTML to a React component.
26 | *
27 | * @param {Options} options The options
28 | * @return {Result} The conversion result
29 | */
30 | module.exports = function(options) {
31 | const ast = htmlToAst(options);
32 | const validation = validateAst(ast.roots);
33 |
34 | const warnings = validation.warnings;
35 | const errors = validation.errors;
36 |
37 | if (errors.length > 0) {
38 | return { errors, warnings, reactStr: '' };
39 | }
40 |
41 | const reactStr = astToReact(ast);
42 | return { reactStr, warnings, errors: [] };
43 | };
44 |
--------------------------------------------------------------------------------
/lib/validate-ast/rules/no-imports-in-components.spec.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const chai = require('chai');
4 | const expect = chai.expect;
5 |
6 | const noImportInComponents = require('./no-imports-in-components');
7 | const result = require('../result');
8 |
9 | module.exports = function() {
10 | describe('no-imports-in-components', function() {
11 | it(`should accept valid ''`, function() {
12 | const ast = [{
13 | name: 'template',
14 | children: [{
15 | name: 'div',
16 | children: [{
17 | name: 'span',
18 | children: []
19 | }]
20 | }]
21 | }];
22 | const res = noImportInComponents(ast).build();
23 |
24 | expect(res).to.deep.equal(result.empty().build());
25 | });
26 |
27 | it(`should reject ' ' in ''`, function() {
28 | const ast = [{
29 | name: 'template',
30 | children: [{
31 | name: 'div',
32 | children: [{
33 | name: 'link',
34 | meta: {}
35 | }]
36 | }]
37 | }];
38 | const res = noImportInComponents(ast).build();
39 |
40 | expect(res.errors.length).to.equal(1);
41 | });
42 | });
43 | };
44 |
--------------------------------------------------------------------------------
/integration-tests/components/bindings.jsx:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | import React from 'react';
4 |
5 | export default function(props) {
6 | return (
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | { props.value }
16 |
17 |
18 | { props.value }
19 |
20 |
21 | { props.value }
22 |
23 |
24 | { props.value }
25 |
26 |
27 | { props.value + ' ' + otherValue }
28 |
29 |
30 | Hello { props.person }
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 | );
46 | }
47 |
--------------------------------------------------------------------------------
/lib/validate-ast/rules/single-component-child.spec.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const chai = require('chai');
4 | const expect = chai.expect;
5 |
6 | const singleComponentChild = require('./single-component-child');
7 | const result = require('../result');
8 |
9 | module.exports = function() {
10 | describe('single-component-child', function() {
11 | it(`should reject absence of child`, function() {
12 | const ast = [{
13 | name: 'template',
14 | attrs: {},
15 | meta: {},
16 | children: []
17 | }];
18 | const res = singleComponentChild(ast).build();
19 |
20 | expect(res.errors.length).to.equal(1);
21 | });
22 |
23 | it(`should reject multiple of children`, function() {
24 | const ast = [{
25 | name: 'template',
26 | attrs: {},
27 | meta: {},
28 | children: [{}, {}]
29 | }];
30 | const res = singleComponentChild(ast).build();
31 |
32 | expect(res.errors.length).to.equal(1);
33 | });
34 |
35 | it(`should accept single child`, function() {
36 | const ast = [{
37 | name: 'template',
38 | attrs: {},
39 | meta: {},
40 | children: [{}]
41 | }];
42 | const res = singleComponentChild(ast).build();
43 |
44 | expect(res).to.deep.equal(result.empty().build());
45 | });
46 | });
47 | };
48 |
--------------------------------------------------------------------------------
/lib/validate-ast/rules/single-control-child.spec.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const chai = require('chai');
4 | const expect = chai.expect;
5 |
6 | const singleControlChild = require('./single-control-child');
7 | const result = require('../result');
8 |
9 | module.exports = function() {
10 | describe('single-control-child', function() {
11 | it(`should reject absence of child`, function() {
12 | const ast = [{
13 | name: 'render',
14 | meta: {},
15 | children: []
16 | }];
17 | const res = singleControlChild(ast).build();
18 |
19 | expect(res.errors.length).to.equal(1);
20 | });
21 |
22 | it(`should reject multiple of children`, function() {
23 | const ast = [{
24 | name: 'render',
25 | meta: {},
26 | children: [
27 | { name: 'div', children: [] },
28 | { name: 'div', children: [] }
29 | ]
30 | }];
31 | const res = singleControlChild(ast).build();
32 |
33 | expect(res.errors.length).to.equal(1);
34 | });
35 |
36 | it(`should accept single child`, function() {
37 | const ast = [{
38 | name: 'render',
39 | children: [{ name: 'div', children: [] }]
40 | }];
41 | const res = singleControlChild(ast).build();
42 |
43 | expect(res).to.deep.equal(result.empty().build());
44 | });
45 | });
46 | };
47 |
--------------------------------------------------------------------------------
/scripts/prepare-release.js:
--------------------------------------------------------------------------------
1 | /* eslint no-console: "off" */
2 | 'use strict';
3 |
4 | const fs = require('fs-promise');
5 | const glob = require('glob');
6 | const path = require('path');
7 | const omit = require('lodash.omit');
8 |
9 | function absPath() {
10 | return path.join(__dirname, '..', ...arguments);
11 | }
12 |
13 | const pkgOmit = [
14 | 'private',
15 | 'scripts',
16 | 'eslintConfig',
17 | 'devDependencies'
18 | ];
19 |
20 | const otherFilesToCopy = [
21 | absPath('LICENSE'),
22 | absPath('README.md')
23 | ];
24 |
25 | function copyFile(options) {
26 | return fs.readFile(options.src, 'utf8')
27 | .then(content => fs.outputFile(options.dst, content));
28 | }
29 |
30 | function copyAllSources() {
31 | const sources = glob.sync(absPath('@(lib|loader)/**/*.js'), { ignore: '**/*.spec.js' })
32 | .concat(otherFilesToCopy);
33 | const copySources = sources.map(src => copyFile({
34 | src,
35 | dst: absPath('dist', path.relative(absPath(''), src))
36 | }));
37 | const copyPackage = fs.readJson(absPath('package.json'))
38 | .then(pkg => fs.writeJson(absPath('dist', 'package.json'), omit(pkg, pkgOmit)));
39 |
40 | return Promise.all(copySources.concat([ copyPackage ]));
41 | }
42 |
43 | fs.ensureDir(absPath('dist'))
44 | .then(copyAllSources)
45 | .catch(err => {
46 | console.error(err);
47 | process.exit(1);
48 | });
49 |
--------------------------------------------------------------------------------
/lib/validate-ast/rules/components-attributes.spec.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const chai = require('chai');
4 | const expect = chai.expect;
5 |
6 | const componentsAttributes = require('./components-attributes');
7 | const result = require('../result');
8 |
9 | module.exports = function() {
10 | describe('components-attributes', function() {
11 | it(`should accept ''`, function() {
12 | const ast = [{
13 | name: 'template',
14 | attrs: { default: true, id: 'wat' },
15 | children: []
16 | }];
17 | const res = componentsAttributes(ast).build();
18 |
19 | expect(res).to.deep.equal(result.empty().build());
20 | });
21 |
22 | it(`should warn about missing attribute 'id' in ''`, function() {
23 | const ast = [{
24 | name: 'template',
25 | attrs: { default: true },
26 | children: [],
27 | meta: {}
28 | }];
29 | const res = componentsAttributes(ast).build();
30 |
31 | expect(res.warnings.length).to.equal(1);
32 | });
33 |
34 | it(`should reject missing attribute 'id' in ''`, function() {
35 | const ast = [{
36 | name: 'template',
37 | attrs: {},
38 | children: [],
39 | meta: {}
40 | }];
41 | const res = componentsAttributes(ast).build();
42 |
43 | expect(res.errors.length).to.equal(1);
44 | });
45 | });
46 | };
47 |
--------------------------------------------------------------------------------
/lib/validate-ast/rules/single-default-component.spec.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const chai = require('chai');
4 | const expect = chai.expect;
5 |
6 | const singleDefaultComponent = require('./single-default-component');
7 | const result = require('../result');
8 |
9 | module.exports = function() {
10 | describe('single-default-component', function() {
11 | it(`should accept absence of ''`, function() {
12 | const ast = [{
13 | name: 'template',
14 | attrs: {}
15 | }];
16 | const res = singleDefaultComponent(ast).build();
17 |
18 | expect(res).to.deep.equal(result.empty().build());
19 | });
20 |
21 | it(`should accept a single ''`, function() {
22 | const ast = [{
23 | name: 'template',
24 | attrs: { default: '' }
25 | }];
26 | const res = singleDefaultComponent(ast).build();
27 |
28 | expect(res).to.deep.equal(result.empty().build());
29 | });
30 |
31 | it(`should reject multiple ''`, function() {
32 | const ast = [
33 | {
34 | name: 'template',
35 | attrs: { default: '' },
36 | meta: {}
37 | },
38 | {
39 | name: 'template',
40 | attrs: { default: '' },
41 | meta: {}
42 | }
43 | ];
44 | const res = singleDefaultComponent(ast).build();
45 |
46 | expect(res.errors.length).to.equal(2);
47 | });
48 | });
49 | };
50 |
--------------------------------------------------------------------------------
/lib/validate-ast/rules/imports-relation-types.spec.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const chai = require('chai');
4 | const expect = chai.expect;
5 |
6 | const constants = require('../../constants');
7 | const importsRelationTypes = require('./imports-relation-types');
8 | const result = require('../result');
9 |
10 | const importAttrs = constants.attributes.import;
11 | const types = Object.keys(importAttrs.types)
12 | .map(k => importAttrs.types[k]);
13 |
14 | module.exports = function() {
15 | describe('imports-relation-types', function() {
16 | types.forEach(type => {
17 | it(`should accept ' '`, function() {
18 | const ast = [{
19 | name: 'link',
20 | attrs: { rel: type }
21 | }];
22 | const res = importsRelationTypes(ast).build();
23 |
24 | expect(res).to.deep.equal(result.empty().build());
25 | });
26 | });
27 |
28 | it(`should reject ' '`, function() {
29 | const ast = [{
30 | name: 'link',
31 | attrs: {},
32 | meta: {}
33 | }];
34 | const res = importsRelationTypes(ast).build();
35 |
36 | expect(res.errors.length).to.equal(1);
37 | });
38 |
39 | it(`should reject unknown type ' '`, function() {
40 | const ast = [{
41 | name: 'link',
42 | attrs: { rel: 'hello' },
43 | meta: {}
44 | }];
45 | const res = importsRelationTypes(ast).build();
46 |
47 | expect(res.errors.length).to.equal(1);
48 | });
49 | });
50 | };
51 |
--------------------------------------------------------------------------------
/lib/ast-to-react/render-components.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const constants = require('../constants');
4 | const renderComponent = require('./render-component');
5 |
6 | const componentsAttrs = constants.attributes.components;
7 |
8 | function renderDefaultComponent(node, tagToVar) {
9 | const id = node.attrs[componentsAttrs.ID];
10 | let varName = '';
11 | let sep = '';
12 | if (id) {
13 | varName = tagToVar[id];
14 | sep = ' ';
15 | }
16 | const content = renderComponent(node, tagToVar);
17 | const component = `export default function${sep}${varName}(props) {\n${content}}\n`;
18 | let displayName = '';
19 | if (varName) {
20 | displayName = `${varName}.displayName = '${varName}';\n`;
21 | }
22 | return `${component}${displayName}`;
23 | }
24 |
25 | function renderNamedComponent(node, tagToVar) {
26 | const varName = tagToVar[node.attrs[componentsAttrs.ID]];
27 | const content = renderComponent(node, tagToVar);
28 | const component = `export function ${varName}(props) {\n${content}}\n`;
29 | const displayName = `${varName}.displayName = '${varName}';\n`;
30 | return `${component}${displayName}`;
31 | }
32 |
33 | function renderComponents(options) {
34 | const renderedComponents = options.namedNodes
35 | .map(node => renderNamedComponent(node, options.tagToVar));
36 |
37 | if (options.defaultNode) {
38 | return renderedComponents.concat([
39 | renderDefaultComponent(options.defaultNode, options.tagToVar)
40 | ]);
41 | }
42 |
43 | return renderedComponents;
44 | }
45 |
46 | module.exports = renderComponents;
47 |
--------------------------------------------------------------------------------
/lib/validate-ast/rules/style-imports-attributes.spec.js:
--------------------------------------------------------------------------------
1 | /* eslint no-unused-expressions: "off" */
2 | 'use strict';
3 |
4 | const chai = require('chai');
5 | const expect = chai.expect;
6 |
7 | const importsAttributes = require('./style-imports-attributes');
8 | const result = require('../result');
9 |
10 | module.exports = function() {
11 | describe('style-imports-attributes', function() {
12 | it('should accept valid global import', function() {
13 | const ast = [{
14 | name: 'link',
15 | attrs: {
16 | rel: 'stylesheet',
17 | href: 'path/to/component'
18 | },
19 | children: []
20 | }];
21 | const res = importsAttributes(ast).build();
22 |
23 | expect(res).to.deep.equal(result.empty().build());
24 | });
25 |
26 | it('should accept valid named import', function() {
27 | const ast = [{
28 | name: 'link',
29 | attrs: {
30 | rel: 'stylesheet',
31 | href: 'path/to/component',
32 | id: 'wat'
33 | },
34 | children: []
35 | }];
36 | const res = importsAttributes(ast).build();
37 |
38 | expect(res).to.deep.equal(result.empty().build());
39 | });
40 |
41 | it(`should reject missing a 'href' attribute`, function() {
42 | const ast = [{
43 | name: 'link',
44 | attrs: { rel: 'stylesheet' },
45 | children: [],
46 | meta: {}
47 | }];
48 | const res = importsAttributes(ast).build();
49 |
50 | expect(res.errors.length).to.equal(1);
51 | });
52 | });
53 | };
54 |
--------------------------------------------------------------------------------
/lib/validate-ast/rules/unique-ids.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const constants = require('../../constants');
4 | const result = require('../result');
5 |
6 | const importAttrs = constants.attributes.import;
7 | const componentAttrs = constants.attributes.components;
8 |
9 | function createReducer(attrName) {
10 | return (acc, node) => {
11 | const id = node.attrs[attrName];
12 | if (id) {
13 | if (acc.ids[id]) {
14 | return {
15 | ids: acc.ids,
16 | result: acc.result.concat(result.withError(
17 | `Id ${id} is already defined`,
18 | node.meta.line
19 | ))
20 | };
21 | }
22 |
23 | return {
24 | ids: Object.assign({}, acc.ids, { [id]: true }),
25 | result: acc.result
26 | };
27 | }
28 |
29 | return acc;
30 | };
31 | }
32 |
33 | function extractComponentId(acc, node) {
34 | return createReducer(componentAttrs.ID)(acc, node);
35 | }
36 |
37 | function extractImportIds(acc, node) {
38 | const reducer = createReducer(importAttrs.ALIAS);
39 | return node.children.reduce(reducer, reducer(acc, node));
40 | }
41 |
42 | function extractIds(acc, node) {
43 | if (node.name === constants.tags.COMPONENT) {
44 | return extractComponentId(acc, node);
45 | }
46 | return extractImportIds(acc, node);
47 | }
48 |
49 | function uniqueIds(ast) {
50 | return ast
51 | .filter(node => (
52 | node.name === constants.tags.COMPONENT
53 | || (node.name === constants.tags.IMPORT
54 | && node.attrs[importAttrs.TYPE] === importAttrs.types.COMPONENT)
55 | ))
56 | .reduce(extractIds, { ids: {}, result: result.empty() })
57 | .result;
58 | }
59 |
60 | module.exports = uniqueIds;
61 |
--------------------------------------------------------------------------------
/lib/validate-ast/index.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const result = require('./result');
4 |
5 | const componentAttributes = require('./rules/components-attributes');
6 | const componentImportsAttributes = require('./rules/component-imports-attributes');
7 | const componentImportsNesting = require('./rules/component-imports-nesting');
8 | const importsRelationTypes = require('./rules/imports-relation-types');
9 | const loopAttributes = require('./rules/loop-attributes');
10 | const noComponentNesting = require('./rules/no-component-nesting');
11 | const noControlMerge = require('./rules/no-control-merge');
12 | const noImportInComponents = require('./rules/no-imports-in-components');
13 | const rootElements = require('./rules/root-elements');
14 | const singleComponentChild = require('./rules/single-component-child');
15 | const singleControlChild = require('./rules/single-control-child');
16 | const singleDefaultComponent = require('./rules/single-default-component');
17 | const styleImportsAttributes = require('./rules/style-imports-attributes');
18 | const styleImportsNesting = require('./rules/style-imports-nesting');
19 | const uniqueIds = require('./rules/unique-ids');
20 |
21 | function checkAst(ast) {
22 | const rules = [
23 | rootElements,
24 | singleDefaultComponent,
25 | noComponentNesting,
26 | noImportInComponents,
27 | uniqueIds,
28 | componentImportsNesting,
29 | singleComponentChild,
30 | importsRelationTypes,
31 | styleImportsNesting,
32 | singleControlChild,
33 | noControlMerge,
34 | componentImportsAttributes,
35 | styleImportsAttributes,
36 | componentAttributes,
37 | loopAttributes
38 | ];
39 |
40 | return rules
41 | .map(rule => rule(ast))
42 | .reduce(result.reducer, result.empty())
43 | .build();
44 | }
45 |
46 | module.exports = checkAst;
47 |
--------------------------------------------------------------------------------
/lib/validate-ast/rules/component-imports-attributes.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const constants = require('../../constants');
4 | const result = require('../result');
5 |
6 | const importAttrs = constants.attributes.import;
7 |
8 | function defaultImport(node) {
9 | let res = result.empty();
10 |
11 | if (!node.attrs[importAttrs.PATH]) {
12 | res = result.withError(
13 | `A component import must have the attribute '${importAttrs.PATH}'`,
14 | node.meta.line
15 | );
16 | }
17 |
18 | if (node.children.length === 0 && !node.attrs[importAttrs.ALIAS]) {
19 | res = res.concat(result.withError(
20 | `A component default import must have the attribute '${importAttrs.ALIAS}'`,
21 | node.meta.line
22 | ));
23 | }
24 |
25 | return res;
26 | }
27 |
28 | function namedImport(node) {
29 | if (!node.attrs[importAttrs.ALIAS]) {
30 | return result.withError(
31 | `A component named import must have the attribute '${importAttrs.ALIAS}'`,
32 | node.meta.line
33 | );
34 | }
35 |
36 | if (node.attrs[importAttrs.ALIAS] === node.attrs[importAttrs.NAMED]) {
37 | return result.withWarning(
38 | `The '${importAttrs.NAMED}' attribute of a component named import should`
39 | + ` be omitted if it is the same as the '${importAttrs.ALIAS}' attribute`,
40 | node.meta.line
41 | );
42 | }
43 |
44 | return result.empty();
45 | }
46 |
47 | function importAttributes(node) {
48 | const childrenResult = node.children
49 | .map(namedImport)
50 | .reduce(result.reducer, result.empty());
51 |
52 | return defaultImport(node).concat(childrenResult);
53 | }
54 |
55 | function importsAttributes(ast) {
56 | return ast
57 | .filter(node => (
58 | node.name === constants.tags.IMPORT
59 | && node.attrs[importAttrs.TYPE] === importAttrs.types.COMPONENT
60 | ))
61 | .map(importAttributes)
62 | .reduce(result.reducer, result.empty());
63 | }
64 |
65 | module.exports = importsAttributes;
66 |
--------------------------------------------------------------------------------
/demo/webpack.config.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const HotModuleReplacementPlugin = require('webpack').HotModuleReplacementPlugin;
4 | const HtmlWebpackPlugin = require('html-webpack-plugin');
5 | const path = require('path');
6 |
7 | module.exports = function(options) {
8 | const hotModuleReplacementPlugin = new HotModuleReplacementPlugin();
9 | const htmlWebpackPlugin = new HtmlWebpackPlugin({
10 | template: options.html,
11 | filename: 'index.html',
12 | inject: 'body'
13 | });
14 |
15 | return {
16 | devtool: 'eval',
17 |
18 | entry: [
19 | `webpack-dev-server/client?http://localhost:${options.port}`,
20 | 'webpack/hot/only-dev-server',
21 | options.entry
22 | ],
23 |
24 | output: {
25 | path: options.outputDir,
26 | publicPath: '/'
27 | },
28 |
29 | module: {
30 | loaders: [
31 | {
32 | test: /\.(js|jsx)$/,
33 | exclude: /node_modules/,
34 | loader: 'babel'
35 | },
36 | {
37 | test: /\.jsx\.html$/,
38 | exclude: /node_modules/,
39 | loader: 'babel!react-pure-html-component'
40 | },
41 | {
42 | test: /\.svg$/,
43 | loader: 'file'
44 | },
45 | {
46 | test: /\.json$/,
47 | loader: 'json'
48 | },
49 | {
50 | test: /\.scss$/,
51 | exclude: /node_modules/,
52 | loaders: [
53 | 'style',
54 | `css?camelCase&modules&importLoaders=1&localIdentName=[hash:base64:5]`,
55 | 'sass'
56 | ]
57 | }
58 | ]
59 | },
60 |
61 | plugins: [
62 | hotModuleReplacementPlugin,
63 | htmlWebpackPlugin
64 | ],
65 |
66 | resolveLoader: {
67 | alias: {
68 | 'react-pure-html-component': path.join(__dirname, '..', 'loader', 'index')
69 | }
70 | },
71 |
72 | resolve: {
73 | root: __dirname,
74 | extensions: [ '', '.js', '.jsx', '.jsx.html', '.scss', '.json' ]
75 | }
76 | };
77 | };
78 |
--------------------------------------------------------------------------------
/lib/validate-ast/rules/no-control-merge.spec.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const chai = require('chai');
4 | const expect = chai.expect;
5 |
6 | const noControlMerge = require('./no-control-merge');
7 | const result = require('../result');
8 |
9 | module.exports = function() {
10 | describe('no-control-merge', function() {
11 | it(`should reject ''`, function() {
12 | const ast = [{
13 | name: 'render',
14 | attrs: { 'for-each': '', 'if': '' },
15 | meta: {},
16 | children: []
17 | }];
18 | const res = noControlMerge(ast).build();
19 |
20 | expect(res.errors.length).to.equal(1);
21 | });
22 |
23 | it(`should reject absence of conditional type`, function() {
24 | const ast = [{
25 | name: 'render',
26 | attrs: {},
27 | meta: {},
28 | children: []
29 | }];
30 | const res = noControlMerge(ast).build();
31 |
32 | expect(res.errors.length).to.equal(1);
33 | });
34 |
35 | it(`should accept a `, function() {
36 | const ast = [{
37 | name: 'render',
38 | attrs: { 'for-each': '' },
39 | children: []
40 | }];
41 | const res = noControlMerge(ast).build();
42 |
43 | expect(res).to.deep.equal(result.empty().build());
44 | });
45 |
46 | it(`should accept a `, function() {
47 | const ast = [{
48 | name: 'render',
49 | attrs: { if: '' },
50 | children: []
51 | }];
52 | const res = noControlMerge(ast).build();
53 |
54 | expect(res).to.deep.equal(result.empty().build());
55 | });
56 |
57 | it(`should accept nested controls`, function() {
58 | const ast = [{
59 | name: 'render',
60 | attrs: { if: '' },
61 | children: [
62 | { name: 'render', attrs: { 'for-each': '' }, children: [] }
63 | ]
64 | }];
65 | const res = noControlMerge(ast).build();
66 |
67 | expect(res).to.deep.equal(result.empty().build());
68 | });
69 | });
70 | };
71 |
--------------------------------------------------------------------------------
/lib/html-to-ast/index.spec.js:
--------------------------------------------------------------------------------
1 | /* eslint no-unused-expressions: "off" */
2 | 'use strict';
3 |
4 | const chai = require('chai');
5 | const expect = chai.expect;
6 |
7 | const htmlToAst = require('./');
8 |
9 | describe('html-to-ast', function() {
10 | it('empty HTML should give empty roots', function() {
11 | const res = htmlToAst({ html: '' });
12 | expect(res).to.deep.equal({ roots: [] });
13 | });
14 |
15 | it('single HTML element should give single root', function() {
16 | const res = htmlToAst({ html: '
' });
17 | expect(res.roots).to.have.lengthOf(1);
18 | });
19 |
20 | it('HTML element has corresponding type', function() {
21 | const res = htmlToAst({ html: '
' });
22 | expect(res.roots[0].type).to.equal(htmlToAst.types.TAG);
23 | });
24 |
25 | it('HTML element with no attributes', function() {
26 | const res = htmlToAst({ html: '
' });
27 | expect(res.roots[0].attrs).to.deep.equal({});
28 | });
29 |
30 | it('HTML element with no children', function() {
31 | const res = htmlToAst({ html: '
' });
32 | expect(res.roots[0].children).to.have.lengthOf(0);
33 | });
34 |
35 | it('HTML element has corresponding name', function() {
36 | const res = htmlToAst({ html: '
' });
37 | expect(res.roots[0].name).to.equal('div');
38 | });
39 |
40 | it('HTML element has corresponding children count', function() {
41 | const res = htmlToAst({ html: '' });
42 | expect(res.roots[0].children).to.have.lengthOf(1);
43 | });
44 |
45 | it('HTML element has corresponding child', function() {
46 | const res = htmlToAst({ html: '' });
47 | expect(res.roots[0].children[0].name).to.equal('div');
48 | });
49 |
50 | it('HTML text has corresponding type', function() {
51 | const res = htmlToAst({ html: 'Hello World ' });
52 | expect(res.roots[0].children[0].type).to.equal(htmlToAst.types.TEXT);
53 | });
54 |
55 | it('HTML element with attributes', function() {
56 | const res = htmlToAst({ html: '
' });
57 | expect(res.roots[0].attrs).to.deep.equal({ aaa: 'bbb', ccc: '' });
58 | });
59 | });
60 |
--------------------------------------------------------------------------------
/lib/validate-ast/rules/component-imports-nesting.spec.js:
--------------------------------------------------------------------------------
1 | /* eslint no-unused-expressions: "off" */
2 | 'use strict';
3 |
4 | const chai = require('chai');
5 | const expect = chai.expect;
6 |
7 | const importsNesting = require('./component-imports-nesting');
8 | const result = require('../result');
9 |
10 | module.exports = function() {
11 | describe('component-imports-nesting', function() {
12 | it(`should accept a unique ' ' element`, function() {
13 | const ast = [{ name: 'link', attrs: { rel: 'import' }, children: [] }];
14 | const res = importsNesting(ast).build();
15 |
16 | expect(res).to.deep.equal(result.empty().build());
17 | });
18 |
19 | it(`should reject non inner ' ' elements`, function() {
20 | const ast = [{
21 | name: 'link',
22 | attrs: { rel: 'import' },
23 | children: [
24 | { name: 'div', children: [], meta: {} },
25 | { name: 'div', children: [], meta: {} }
26 | ],
27 | meta: {}
28 | }];
29 | const res = importsNesting(ast).build();
30 |
31 | expect(res.errors.length).to.equal(2);
32 | });
33 |
34 | it(`should accept inner ' ' elements`, function() {
35 | const ast = [{
36 | name: 'link',
37 | attrs: { rel: 'import' },
38 | children: [
39 | { name: 'link', attrs: { rel: 'import' }, children: [] },
40 | { name: 'link', attrs: { rel: 'import' }, children: [] }
41 | ]
42 | }];
43 | const res = importsNesting(ast).build();
44 |
45 | expect(res).to.deep.equal(result.empty().build());
46 | });
47 |
48 | it(`should reject children of inner ' ' elements`, function() {
49 | const ast = [{
50 | name: 'link',
51 | attrs: { rel: 'import' },
52 | children: [{
53 | name: 'link',
54 | attrs: { rel: 'import' },
55 | children: [{ name: 'whatever' }],
56 | meta: {}
57 | }],
58 | meta: {}
59 | }];
60 | const res = importsNesting(ast).build();
61 |
62 | expect(res.errors.length).to.equal(1);
63 | });
64 | });
65 | };
66 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-pure-html-component-loader",
3 | "version": "1.0.1",
4 | "description": "A Webpack loader allowing imports of HTML components as they were React pure functional components",
5 | "main": "./loader/index.js",
6 | "private": true,
7 | "scripts": {
8 | "clean": "node scripts/clean",
9 | "demo": "node demo",
10 | "lint": "eslint **/*.js **/*.jsx --ignore-pattern integration-tests/components",
11 | "unit-test": "mocha 'lib/**/*.spec.js'",
12 | "integration-test": "mocha 'integration-tests/**/*.spec.js'",
13 | "test": "npm run unit-test && npm run integration-test",
14 | "check-all": "npm run lint && npm run test"
15 | },
16 | "repository": {
17 | "type": "git",
18 | "url": "git+https://github.com/simonrelet/react-pure-html-component-loader.git"
19 | },
20 | "keywords": [
21 | "html",
22 | "components",
23 | "pure",
24 | "functional",
25 | "react",
26 | "webpack",
27 | "loader"
28 | ],
29 | "author": "Simon Relet",
30 | "license": "MIT",
31 | "bugs": {
32 | "url": "https://github.com/simonrelet/react-pure-html-component-loader/issues"
33 | },
34 | "homepage": "https://github.com/simonrelet/react-pure-html-component-loader#readme",
35 | "eslintConfig": {
36 | "extends": "react-simonrelet"
37 | },
38 | "devDependencies": {
39 | "axios": "^0.15.2",
40 | "babel-core": "^6.17.0",
41 | "babel-loader": "^6.2.5",
42 | "babel-plugin-transform-object-rest-spread": "^6.16.0",
43 | "babel-preset-es2015": "^6.16.0",
44 | "babel-preset-react": "^6.16.0",
45 | "chai": "^3.5.0",
46 | "css-loader": "^0.26.1",
47 | "del": "^2.2.2",
48 | "eslint": "^3.5.0",
49 | "eslint-config-react-simonrelet": "^1.0.0",
50 | "extract-text-webpack-plugin": "^1.0.1",
51 | "file-loader": "^0.10.0",
52 | "fs-promise": "^1.0.0",
53 | "glob": "^7.1.1",
54 | "html-webpack-plugin": "^2.22.0",
55 | "json-loader": "^0.5.4",
56 | "lodash.omit": "^4.5.0",
57 | "minimist": "^1.2.0",
58 | "mocha": "^3.1.2",
59 | "node-sass": "^4.4.0",
60 | "react": "^15.3.2",
61 | "react-dom": "^15.3.2",
62 | "sass-loader": "^4.0.2",
63 | "style-loader": "^0.13.1",
64 | "webpack": "^1.13.2",
65 | "webpack-dev-server": "^1.16.2"
66 | },
67 | "dependencies": {
68 | "change-case": "^3.0.0",
69 | "htmlparser2": "^3.9.1"
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/lib/ast-to-react/extract-components.spec.js:
--------------------------------------------------------------------------------
1 | /* eslint no-unused-expressions: "off" */
2 | 'use strict';
3 |
4 | const chai = require('chai');
5 | const expect = chai.expect;
6 |
7 | const extractComponents = require('./extract-components');
8 |
9 | describe('extract-components', function() {
10 | it('should be empty if there is no nodes', function() {
11 | const res = extractComponents([]);
12 |
13 | expect(res).to.deep.equal({
14 | tagToVar: {},
15 | namedNodes: [],
16 | defaultNode: undefined
17 | });
18 | });
19 |
20 | it('should extract the default node', function() {
21 | const node = { name: 'template', attrs: { default: '' } };
22 | const res = extractComponents([ node ]);
23 |
24 | expect(res.defaultNode).to.deep.equal(node);
25 | });
26 |
27 | it('should set the id of the default node', function() {
28 | const node = {
29 | name: 'template',
30 | attrs: {
31 | default: '',
32 | id: 'default-component'
33 | }
34 | };
35 | const res = extractComponents([ node ]);
36 |
37 | expect(res).to.deep.equal({
38 | tagToVar: { 'default-component': 'DefaultComponent' },
39 | namedNodes: [],
40 | defaultNode: node
41 | });
42 | });
43 |
44 | it('should extract the named nodes', function() {
45 | const nodes = [
46 | { name: 'template', attrs: { id: 'named-one' } },
47 | { name: 'template', attrs: { id: 'named-two' } },
48 | { name: 'template', attrs: { id: 'named-three' } }
49 | ];
50 | const res = extractComponents(nodes);
51 |
52 | expect(res.namedNodes).to.deep.equal(nodes);
53 | expect(res.tagToVar).to.deep.equal({
54 | 'named-one': 'NamedOne',
55 | 'named-two': 'NamedTwo',
56 | 'named-three': 'NamedThree'
57 | });
58 | });
59 |
60 | it('should ignore all other nodes', function() {
61 | const namedNode = { name: 'template', attrs: { id: 'named-component' } };
62 | const defaultNode = { name: 'template', attrs: { default: '' } };
63 | const nodes = [
64 | namedNode,
65 | defaultNode,
66 | { name: 'link', attrs: { href: 'href' } },
67 | { name: 'div', attrs: { class: 'some-class' } }
68 | ];
69 | const res = extractComponents(nodes);
70 |
71 | expect(res).to.deep.equal({
72 | tagToVar: { 'named-component': 'NamedComponent' },
73 | namedNodes: [ namedNode ],
74 | defaultNode
75 | });
76 | });
77 | });
78 |
--------------------------------------------------------------------------------
/lib/html-to-ast/index.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const htmlParser = require('htmlparser2');
4 |
5 | const loggers = require('../loggers');
6 |
7 | const types = {
8 | TAG: 'tag',
9 | TEXT: 'text'
10 | };
11 |
12 | const htmlParserOptions = { xmlMode: true };
13 |
14 | function createHandler(verbosity) {
15 | return {
16 | line: 0,
17 | current: undefined,
18 | roots: [],
19 | logger: loggers(loggers.levels[verbosity]),
20 |
21 | onopentag,
22 | ontext,
23 | onclosetag,
24 | onerror,
25 | getResult,
26 | nextLine
27 | };
28 | }
29 |
30 | function onopentag(name, attributes) {
31 | this.logger.log(`onopentag: ${name}, ${loggers.str(attributes)}`);
32 |
33 | const child = {
34 | type: types.TAG,
35 | name,
36 | parent: this.current,
37 | attrs: attributes,
38 | children: [],
39 | meta: { line: this.line }
40 | };
41 |
42 | if (this.current) {
43 | this.current.children.push(child);
44 | }
45 | this.current = child;
46 | }
47 |
48 | function ontext(text) {
49 | const value = text.trim();
50 | if (value !== '') {
51 | this.logger.log(`ontext: ${loggers.str(text)}`);
52 |
53 | if (this.current) {
54 | this.current.children.push({
55 | type: types.TEXT,
56 | value,
57 | children: [],
58 | meta: { line: this.line }
59 | });
60 | }
61 | }
62 | }
63 |
64 | function onclosetag(name) {
65 | this.logger.log(`onclosetag: ${name}`);
66 |
67 | if (this.current && this.current.parent) {
68 | const parent = this.current.parent;
69 | this.current.parent = undefined;
70 | this.current = parent;
71 | } else {
72 | this.roots.push(this.current);
73 | this.current = undefined;
74 | }
75 | }
76 |
77 | function onerror(err) {
78 | this.logger.log(`onerror: ${loggers.str(err)}`);
79 | }
80 |
81 | function getResult() {
82 | return { roots: this.roots };
83 | }
84 |
85 | function nextLine() {
86 | this.line++;
87 | }
88 |
89 | /**
90 | * @typedef {object} AST
91 | * @property {Node[]} roots The root nodes
92 | */
93 |
94 | /**
95 | * @typedef {TagNode|TextNode} Node
96 | * @property {string} type The type of the node
97 | */
98 |
99 | /**
100 | * @typedef {object} TagNode
101 | * @property {string} name The tag name
102 | * @property {object} attrs The attributes
103 | * @property {Node[]} children The children
104 | */
105 |
106 | /**
107 | * @typedef {object} TextNode
108 | * @property {string} value The text value
109 | */
110 |
111 | /**
112 | * @typedef {object} HTAOptions
113 | * @property {string} html The HTML
114 | * @property {string} [verbosity=NONE] The verbosity level. One of NONE, LOG,
115 | * WARNING, ERROR
116 | */
117 |
118 | /**
119 | * Parses an HTML and return its corresponding AST.
120 | *
121 | * @param {HTAOptions} options The options
122 | * @return {AST} The AST
123 | */
124 | function htmlToAst(options) {
125 | const handler = createHandler(options.verbosity);
126 | const parser = new htmlParser.Parser(handler, htmlParserOptions);
127 |
128 | options.html.split(/\n\r?/).forEach(line => {
129 | handler.nextLine();
130 | parser.write(line);
131 | });
132 |
133 | parser.end();
134 | return handler.getResult();
135 | }
136 |
137 | module.exports = htmlToAst;
138 | module.exports.types = types;
139 |
--------------------------------------------------------------------------------
/lib/validate-ast/rules/unique-ids.spec.js:
--------------------------------------------------------------------------------
1 | /* eslint no-unused-expressions: "off" */
2 | 'use strict';
3 |
4 | const chai = require('chai');
5 | const expect = chai.expect;
6 |
7 | const uniqueIds = require('./unique-ids');
8 | const result = require('../result');
9 |
10 | module.exports = function() {
11 | describe('unique-ids', function() {
12 | it('should accept unique ids of ', function() {
13 | const ast = [
14 | { name: 'template', attrs: { id: 'some-id-1' } },
15 | { name: 'template', attrs: { id: 'some-id-2' } },
16 | { name: 'template', attrs: { id: 'some-id-3' } }
17 | ];
18 | const res = uniqueIds(ast).build();
19 |
20 | expect(res).to.deep.equal(result.empty().build());
21 | });
22 |
23 | it('should accept unique ids of ', function() {
24 | const ast = [
25 | { name: 'link', attrs: { rel: 'import', id: 'some-id-1' }, children: [] },
26 | { name: 'link', attrs: { rel: 'import', id: 'some-id-2' }, children: [] },
27 | { name: 'link', attrs: { rel: 'import', id: 'some-id-3' }, children: [] }
28 | ];
29 | const res = uniqueIds(ast).build();
30 |
31 | expect(res).to.deep.equal(result.empty().build());
32 | });
33 |
34 | it('should accept unique ids of and ', function() {
35 | const ast = [
36 | { name: 'link', attrs: { rel: 'import', id: 'some-id-1' }, children: [] },
37 | { name: 'link', attrs: { rel: 'import', id: 'some-id-2' }, children: [] },
38 | { name: 'link', attrs: { rel: 'import', id: 'some-id-3' }, children: [] },
39 | { name: 'template', attrs: { id: 'some-id-4' } },
40 | { name: 'template', attrs: { id: 'some-id-5' } }
41 | ];
42 | const res = uniqueIds(ast).build();
43 |
44 | expect(res).to.deep.equal(result.empty().build());
45 | });
46 |
47 | it('should reject identical ids for ', function() {
48 | const ast = [
49 | { name: 'template', attrs: { id: 'some-id' } },
50 | { name: 'template', attrs: { id: 'some-id' }, meta: {} }
51 | ];
52 | const res = uniqueIds(ast).build();
53 |
54 | expect(res.errors.length).to.equal(1);
55 | });
56 |
57 | it('should reject identical ids for ', function() {
58 | const ast = [
59 | {
60 | name: 'link',
61 | attrs: { rel: 'import', id: 'some-id' },
62 | children: []
63 | },
64 | {
65 | name: 'link',
66 | attrs: { rel: 'import', id: 'some-id' },
67 | children: [],
68 | meta: {}
69 | }
70 | ];
71 | const res = uniqueIds(ast).build();
72 |
73 | expect(res.errors.length).to.equal(1);
74 | });
75 |
76 | it('should reject identical ids for nested ', function() {
77 | const ast = [{
78 | name: 'link',
79 | attrs: { rel: 'import', id: 'some-id' },
80 | children: [
81 | { name: 'link', attrs: { id: 'some-id' }, meta: {} }
82 | ]
83 | }];
84 | const res = uniqueIds(ast).build();
85 |
86 | expect(res.errors.length).to.equal(1);
87 | });
88 |
89 | it('should reject identical ids of and ', function() {
90 | const ast = [
91 | {
92 | name: 'link',
93 | attrs: { rel: 'import' },
94 | children: [
95 | { name: 'link', attrs: { id: 'some-id' } }
96 | ]
97 | },
98 | { name: 'template', attrs: { id: 'some-id' }, meta: {} }
99 | ];
100 | const res = uniqueIds(ast).build();
101 |
102 | expect(res.errors.length).to.equal(1);
103 | });
104 | });
105 | };
106 |
--------------------------------------------------------------------------------
/lib/ast-to-react/extract-imports.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const pascalCase = require('change-case').pascalCase;
4 |
5 | const constants = require('../constants');
6 |
7 | const importAttrs = constants.attributes.import;
8 |
9 | function normalizeComponentImport(importNode) {
10 | const res = {
11 | type: importAttrs.types.COMPONENT,
12 | path: importNode.attrs[importAttrs.PATH]
13 | };
14 |
15 | if (importNode.attrs[importAttrs.ALIAS]) {
16 | res.default = {
17 | tagName: importNode.attrs[importAttrs.ALIAS],
18 | varName: pascalCase(importNode.attrs[importAttrs.ALIAS])
19 | };
20 | }
21 |
22 | if (importNode.children.length > 0) {
23 | res.named = importNode.children.map(child => ({
24 | tagName: child.attrs[importAttrs.ALIAS],
25 | varName: pascalCase(child.attrs[importAttrs.ALIAS]),
26 | from: child.attrs[importAttrs.NAMED]
27 | ? pascalCase(child.attrs[importAttrs.NAMED])
28 | : pascalCase(child.attrs[importAttrs.ALIAS])
29 | }));
30 | }
31 |
32 | return res;
33 | }
34 |
35 | function normalizeStylesheetImport(importNode) {
36 | const res = {
37 | type: importAttrs.types.STYLESHEET,
38 | path: importNode.attrs[importAttrs.PATH]
39 | };
40 |
41 | if (importNode.attrs[importAttrs.OBJECT_VALUE]) {
42 | res.value = importNode.attrs[importAttrs.OBJECT_VALUE].replace(
43 | constants.attributes.bindings.STRICT_PATTERN,
44 | '$1'
45 | );
46 | }
47 |
48 | return res;
49 | }
50 |
51 | function normalizeImport(importNode) {
52 | const importType = importNode.attrs[importAttrs.TYPE];
53 | switch (importType) {
54 | case importAttrs.types.COMPONENT:
55 | return normalizeComponentImport(importNode);
56 | case importAttrs.types.STYLESHEET:
57 | return normalizeStylesheetImport(importNode);
58 | default:
59 | throw new Error(`Unknown import type: '${importType}'`);
60 | }
61 | }
62 |
63 | function tagToVar(imports) {
64 | const reducer = (map, i) => {
65 | if (i.default) {
66 | map[i.default.tagName] = i.default.varName;
67 | }
68 |
69 | if (i.named) {
70 | i.named.forEach(n => {
71 | map[n.tagName] = n.varName;
72 | });
73 | }
74 |
75 | return map;
76 | };
77 |
78 | return imports
79 | .filter(i => i.type === importAttrs.types.COMPONENT)
80 | .reduce(reducer, {});
81 | }
82 |
83 | function renderComponentImport(i) {
84 | let varName = '';
85 | if (i.default) {
86 | varName = i.default.varName;
87 | }
88 |
89 | let namedImports = '';
90 | if (i.named) {
91 | const named = i.named.map(n => {
92 | if (n.from === n.varName) {
93 | return n.varName;
94 | }
95 | return `${n.from} as ${n.varName}`;
96 | });
97 | namedImports = `{ ${named.join(', ')} }`;
98 | }
99 |
100 | let sep = '';
101 | if (varName && namedImports) {
102 | sep = ', ';
103 | }
104 |
105 | return `import ${varName}${sep}${namedImports} from '${i.path}';`;
106 | }
107 |
108 | function renderStylesheetImport(i) {
109 | let varName = '';
110 | if (i.value) {
111 | varName = ` ${i.value} from`;
112 | }
113 | return `import${varName} '${i.path}';`;
114 | }
115 |
116 | function renderImports(imports) {
117 | const mapper = i => {
118 | switch (i.type) {
119 | case importAttrs.types.COMPONENT:
120 | return renderComponentImport(i);
121 | case importAttrs.types.STYLESHEET:
122 | return renderStylesheetImport(i);
123 | default:
124 | throw new Error(`Unknown import type: '${i.type}'`);
125 | }
126 | };
127 |
128 | return imports.map(mapper).reduce((a, b) => `${a}${b}\n`, '');
129 | }
130 |
131 | function extractImports(roots) {
132 | const imports = roots
133 | .filter(node => node.name === constants.tags.IMPORT)
134 | .map(normalizeImport);
135 |
136 | return {
137 | tagToVar: tagToVar(imports),
138 | rendered: renderImports(imports)
139 | };
140 | }
141 |
142 | module.exports = extractImports;
143 |
--------------------------------------------------------------------------------
/lib/ast-to-react/extract-imports.spec.js:
--------------------------------------------------------------------------------
1 | /* eslint no-unused-expressions: "off" */
2 | 'use strict';
3 |
4 | const chai = require('chai');
5 | const expect = chai.expect;
6 |
7 | const extractImports = require('./extract-imports');
8 |
9 | describe('extract-imports', function() {
10 | it('should be empty if there is no nodes', function() {
11 | const res = extractImports([]);
12 | expect(res).to.deep.equal({ tagToVar: {}, rendered: '' });
13 | });
14 |
15 | describe('for template imports', function() {
16 | it('should extract a single import', function() {
17 | const node = {
18 | name: 'link',
19 | attrs: { href: 'href', id: 'id-link', rel: 'import' },
20 | children: []
21 | };
22 | const res = extractImports([ node ]);
23 |
24 | expect(res).to.deep.equal({
25 | tagToVar: { 'id-link': 'IdLink' },
26 | rendered: `import IdLink from 'href';\n`
27 | });
28 | });
29 |
30 | it('should extract multiple imports', function() {
31 | const nodes = [
32 | {
33 | name: 'link',
34 | attrs: { href: 'href/one', id: 'id-link-one', rel: 'import' },
35 | children: []
36 | },
37 | {
38 | name: 'link',
39 | attrs: { href: 'href/two', id: 'id-link-two', rel: 'import' },
40 | children: []
41 | }
42 | ];
43 | const res = extractImports(nodes);
44 | const rendered = [
45 | `import IdLinkOne from 'href/one';\n`,
46 | `import IdLinkTwo from 'href/two';\n`
47 | ];
48 |
49 | expect(res).to.deep.equal({
50 | tagToVar: {
51 | 'id-link-one': 'IdLinkOne',
52 | 'id-link-two': 'IdLinkTwo'
53 | },
54 | rendered: rendered.join('')
55 | });
56 | });
57 |
58 | it('should extract a named import', function() {
59 | const node = {
60 | name: 'link',
61 | attrs: { href: 'href', rel: 'import' },
62 | children: [{
63 | name: 'link',
64 | attrs: { id: 'id-link', rel: 'import' }
65 | }]
66 | };
67 | const res = extractImports([ node ]);
68 |
69 | expect(res).to.deep.equal({
70 | tagToVar: { 'id-link': 'IdLink' },
71 | rendered: `import { IdLink } from 'href';\n`
72 | });
73 | });
74 |
75 | it('should extract a named import with alias', function() {
76 | const node = {
77 | name: 'link',
78 | attrs: { href: 'href', rel: 'import' },
79 | children: [{
80 | name: 'link',
81 | attrs: { id: 'id-link-alias', name: 'id-link', rel: 'import' }
82 | }]
83 | };
84 | const res = extractImports([ node ]);
85 |
86 | expect(res).to.deep.equal({
87 | tagToVar: { 'id-link-alias': 'IdLinkAlias' },
88 | rendered: `import { IdLink as IdLinkAlias } from 'href';\n`
89 | });
90 | });
91 |
92 | it('should extract a nested imports', function() {
93 | const node = {
94 | name: 'link',
95 | attrs: { href: 'href', id: 'id-link', rel: 'import' },
96 | children: [{
97 | name: 'link',
98 | attrs: { id: 'id-link-named', rel: 'import' }
99 | }]
100 | };
101 | const res = extractImports([ node ]);
102 |
103 | expect(res).to.deep.equal({
104 | tagToVar: {
105 | 'id-link': 'IdLink',
106 | 'id-link-named': 'IdLinkNamed'
107 | },
108 | rendered: `import IdLink, { IdLinkNamed } from 'href';\n`
109 | });
110 | });
111 | });
112 |
113 | describe('for style imports', function() {
114 | it('should extract global import', function() {
115 | const node = {
116 | name: 'link',
117 | attrs: { href: 'href', rel: 'stylesheet' }
118 | };
119 | const res = extractImports([ node ]);
120 |
121 | expect(res).to.deep.equal({
122 | tagToVar: {},
123 | rendered: `import 'href';\n`
124 | });
125 | });
126 |
127 | it('should extract named import', function() {
128 | const node = {
129 | name: 'link',
130 | attrs: { href: 'href', id: '{{ style }}', rel: 'stylesheet' }
131 | };
132 | const res = extractImports([ node ]);
133 |
134 | expect(res).to.deep.equal({
135 | tagToVar: {},
136 | rendered: `import style from 'href';\n`
137 | });
138 | });
139 | });
140 | });
141 |
--------------------------------------------------------------------------------
/lib/validate-ast/rules/component-imports-attributes.spec.js:
--------------------------------------------------------------------------------
1 | /* eslint no-unused-expressions: "off" */
2 | 'use strict';
3 |
4 | const chai = require('chai');
5 | const expect = chai.expect;
6 |
7 | const importsAttributes = require('./component-imports-attributes');
8 | const result = require('../result');
9 |
10 | module.exports = function() {
11 | describe('component-imports-attributes', function() {
12 | it('should accept valid default import', function() {
13 | const ast = [{
14 | name: 'link',
15 | attrs: {
16 | rel: 'import',
17 | href: 'path/to/component',
18 | id: 'wat'
19 | },
20 | children: []
21 | }];
22 | const res = importsAttributes(ast).build();
23 |
24 | expect(res).to.deep.equal(result.empty().build());
25 | });
26 |
27 | it('should accept valid named import', function() {
28 | const ast = [{
29 | name: 'link',
30 | attrs: { rel: 'import', href: 'path/to/component' },
31 | children: [{
32 | name: 'link',
33 | attrs: { rel: 'import', id: 'wat' },
34 | children: []
35 | }]
36 | }];
37 | const res = importsAttributes(ast).build();
38 |
39 | expect(res).to.deep.equal(result.empty().build());
40 | });
41 |
42 | it('should accept valid default and named import', function() {
43 | const ast = [{
44 | name: 'link',
45 | attrs: {
46 | rel: 'import',
47 | href: 'path/to/component',
48 | id: 'wat'
49 | },
50 | children: [{
51 | name: 'link',
52 | attrs: { rel: 'import', id: 'wat' },
53 | children: []
54 | }]
55 | }];
56 | const res = importsAttributes(ast).build();
57 |
58 | expect(res).to.deep.equal(result.empty().build());
59 | });
60 |
61 | it(`should warn about identical 'name' and 'id' attributes for name import`, function() {
62 | const ast = [{
63 | name: 'link',
64 | attrs: { rel: 'import', href: 'path/to/component' },
65 | children: [{
66 | name: 'link',
67 | attrs: {
68 | rel: 'import',
69 | id: 'wat',
70 | name: 'wat'
71 | },
72 | children: [],
73 | meta: {}
74 | }]
75 | }];
76 | const res = importsAttributes(ast).build();
77 |
78 | expect(res.warnings.length).to.equal(1);
79 | });
80 |
81 | it(`should accept different 'name' and 'id' attributes for name import`, function() {
82 | const ast = [{
83 | name: 'link',
84 | attrs: { rel: 'import', href: 'path/to/component' },
85 | children: [{
86 | name: 'link',
87 | attrs: {
88 | rel: 'import',
89 | id: 'wat',
90 | name: 'yop'
91 | },
92 | meta: {}
93 | }]
94 | }];
95 | const res = importsAttributes(ast).build();
96 |
97 | expect(res).to.deep.equal(result.empty().build());
98 | });
99 |
100 | it(`should reject missing an 'id' attribute for default import`, function() {
101 | const ast = [{
102 | name: 'link',
103 | attrs: { rel: 'import', href: 'path/to/component' },
104 | children: [],
105 | meta: {}
106 | }];
107 | const res = importsAttributes(ast).build();
108 |
109 | expect(res.errors.length).to.equal(1);
110 | });
111 |
112 | it(`should reject missing an 'id' attribute for named import`, function() {
113 | const ast = [{
114 | name: 'link',
115 | attrs: { rel: 'import', href: 'path/to/component' },
116 | children: [{
117 | name: 'link',
118 | attrs: { rel: 'import' },
119 | meta: {}
120 | }]
121 | }];
122 | const res = importsAttributes(ast).build();
123 |
124 | expect(res.errors.length).to.equal(1);
125 | });
126 |
127 | it(`should reject missing a 'href' attribute for default import`, function() {
128 | const ast = [{
129 | name: 'link',
130 | attrs: { rel: 'import', id: 'wat' },
131 | children: [],
132 | meta: {}
133 | }];
134 | const res = importsAttributes(ast).build();
135 |
136 | expect(res.errors.length).to.equal(1);
137 | });
138 |
139 | it(`should reject missing a 'href' attribute for named import`, function() {
140 | const ast = [{
141 | name: 'link',
142 | attrs: { rel: 'import' },
143 | children: [{
144 | name: 'link',
145 | attrs: { rel: 'import', id: 'wat' }
146 | }],
147 | meta: {}
148 | }];
149 | const res = importsAttributes(ast).build();
150 |
151 | expect(res.errors.length).to.equal(1);
152 | });
153 |
154 | it(`should reject missing a 'href' and 'id' attribute for default import`, function() {
155 | const ast = [{
156 | name: 'link',
157 | attrs: { rel: 'import' },
158 | children: [],
159 | meta: {}
160 | }];
161 | const res = importsAttributes(ast).build();
162 |
163 | expect(res.errors.length).to.equal(2);
164 | });
165 | });
166 | };
167 |
--------------------------------------------------------------------------------
/lib/ast-to-react/render-component.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const astTypes = require('../html-to-ast').types;
4 | const attributeConversion = require('../attribute-conversion');
5 | const constants = require('../constants');
6 |
7 | const INDENT = ' ';
8 | const bindings = constants.attributes.bindings;
9 | const controlsAttrs = constants.attributes.controls;
10 |
11 | function renderJsxText(node, indent) {
12 | let value = node.value;
13 | if (bindings.PATTERN.test(value)) {
14 | value = value.replace(bindings.PATTERN, '{ $1 }');
15 | }
16 | return `${indent}${value}\n`;
17 | }
18 |
19 | function renderJsxPropsSpreading(value) {
20 | return value.replace(
21 | bindings.STRICT_PATTERN,
22 | '{ ...$1 }'
23 | );
24 | }
25 |
26 | function renderJsxProp(value, attr) {
27 | // Consider the absence or an empty attribute (i.e. `attr` or `attr=""`) as
28 | // `true`.
29 | const nodeValue = value || 'true';
30 |
31 | if (bindings.BOLLEAN_PATTERN.test(nodeValue)) {
32 | value = nodeValue.replace(
33 | bindings.BOLLEAN_PATTERN,
34 | (m, g1) => `{ ${g1.toLowerCase()} }`
35 | );
36 |
37 | // It only contains a binding (i.e. `attr="{{ expression }}")`, in this case
38 | // it should be converted to `attr={ expression }`.
39 | } else if (bindings.STRICT_PATTERN.test(nodeValue)) {
40 | value = nodeValue.replace(
41 | bindings.STRICT_PATTERN,
42 | '{ $1 }'
43 | );
44 |
45 | // It is a string template (i.e. `attr="hello {{ expression }}"`), in this
46 | // case it should be converted to `attr={ `hello ${ expression }` }`.
47 | } else if (bindings.PATTERN.test(nodeValue)) {
48 | const replacement = nodeValue.replace(
49 | bindings.PATTERN,
50 | '$${ $1 }'
51 | );
52 | value = `{ \`${replacement}\` }`;
53 |
54 | // There are no bindings, it is just a string.
55 | } else {
56 | value = `'${nodeValue}'`;
57 | }
58 |
59 | return `${attr}=${value}`;
60 | }
61 |
62 | function renderJsxProps(node) {
63 | const mapper = k => {
64 | const attr = attributeConversion.toJsx(k);
65 | const value = node.attrs[k];
66 |
67 | switch (attr) {
68 | case constants.attributes.PROPS_SPREADING:
69 | return renderJsxPropsSpreading(value);
70 | default:
71 | return renderJsxProp(value, attr);
72 | }
73 | };
74 |
75 | const attrs = Object.keys(node.attrs)
76 | .map(mapper)
77 | .reduce((a, b) => `${a} ${b}`, '');
78 |
79 | return attrs;
80 | }
81 |
82 | function renderJsxBasicTag(node, tagToVar, indent) {
83 | const name = tagToVar[node.name] || node.name;
84 | const openTag = `${indent}<${name}`;
85 | const props = renderJsxProps(node);
86 |
87 | if (node.children.length > 0) {
88 | const closingTag = `${indent}${name}>`;
89 | const children = node.children
90 | .map(child => renderJsxNode(child, tagToVar, `${INDENT}${indent}`))
91 | .join('');
92 |
93 | return `${openTag}${props}>\n${children}${closingTag}\n`;
94 | }
95 |
96 | return `${openTag}${props} />\n`;
97 | }
98 |
99 | function getBlockWrapper(withWrapper) {
100 | if (withWrapper) {
101 | return { open: '{ ', close: ' }' };
102 | }
103 | return { open: '', close: '' };
104 | }
105 |
106 | function renderJsxConditionalTag(node, tagToVar, indent, parentIsControl) {
107 | const block = getBlockWrapper(!parentIsControl);
108 | const test = node.attrs[controlsAttrs.CONDITIONALS_TEST].replace(
109 | bindings.STRICT_PATTERN,
110 | '$1'
111 | );
112 | const condition = `${indent}${block.open}(${test}) && (\n`;
113 | const child = node.children[0];
114 | const childIsControl = child.name === constants.tags.CONTROLS;
115 | const children = childIsControl
116 | ? renderJsxControlsTag(child, tagToVar, `${INDENT}${indent}`, true)
117 | : renderJsxNode(child, tagToVar, `${INDENT}${indent}`);
118 | const closing = `${indent})${block.close}\n`;
119 |
120 | return `${condition}${children}${closing}`;
121 | }
122 |
123 | function renderJsxLoopTag(node, tagToVar, indent, parentIsControl) {
124 | const block = getBlockWrapper(!parentIsControl);
125 | const arrayName = node.attrs[controlsAttrs.LOOP_ARRAY].replace(
126 | bindings.STRICT_PATTERN,
127 | '$1'
128 | );
129 | const varName = node.attrs[controlsAttrs.LOOP_VAR_NAME].replace(
130 | bindings.STRICT_PATTERN,
131 | '$1'
132 | );
133 | const loop = `${indent}${block.open}${arrayName}.map(${varName} => (\n`;
134 | const child = node.children[0];
135 | const childIsControl = child.name === constants.tags.CONTROLS;
136 | const children = childIsControl
137 | ? renderJsxControlsTag(child, tagToVar, `${INDENT}${indent}`, true)
138 | : renderJsxNode(child, tagToVar, `${INDENT}${indent}`);
139 | const closing = `${indent}))${block.close}\n`;
140 |
141 | return `${loop}${children}${closing}`;
142 | }
143 |
144 | function renderJsxControlsTag(node, tagToVar, indent, parentIsControl) {
145 | if (node.attrs[controlsAttrs.CONDITIONALS_TEST]) {
146 | return renderJsxConditionalTag(node, tagToVar, indent, parentIsControl);
147 | }
148 | return renderJsxLoopTag(node, tagToVar, indent, parentIsControl);
149 | }
150 |
151 | function renderJsxTag(node, tagToVar, indent, firstNode) {
152 | switch (node.name) {
153 | case constants.tags.CONTROLS:
154 | return renderJsxControlsTag(node, tagToVar, indent, firstNode);
155 | default:
156 | return renderJsxBasicTag(node, tagToVar, indent);
157 | }
158 | }
159 |
160 | function renderJsxNode(node, tagToVar, indent, firstNode) {
161 | switch (node.type) {
162 | case astTypes.TEXT:
163 | return renderJsxText(node, indent);
164 | default:
165 | return renderJsxTag(node, tagToVar, indent, firstNode);
166 | }
167 | }
168 |
169 | function extractJsx(node, tagToVar) {
170 | const jsx = renderJsxNode(node, tagToVar, `${INDENT}${INDENT}`, true);
171 | return `${INDENT}return (\n${jsx}${INDENT});\n`;
172 | }
173 |
174 | function renderComponent(node, tagToVar) {
175 | // Remove the `` tag.
176 | const component = node.children[0];
177 | return extractJsx(component, tagToVar);
178 | }
179 |
180 | module.exports = renderComponent;
181 | module.exports.renderJsxText = renderJsxText;
182 | module.exports.renderJsxPropsSpreading = renderJsxPropsSpreading;
183 | module.exports.renderJsxProp = renderJsxProp;
184 | module.exports.renderJsxProps = renderJsxProps;
185 | module.exports.renderJsxBasicTag = renderJsxBasicTag;
186 | module.exports.renderJsxConditionalTag = renderJsxConditionalTag;
187 | module.exports.renderJsxLoopTag = renderJsxLoopTag;
188 |
--------------------------------------------------------------------------------
/lib/ast-to-react/render-component.spec.js:
--------------------------------------------------------------------------------
1 | /* eslint no-unused-expressions: "off" */
2 | 'use strict';
3 |
4 | const chai = require('chai');
5 | const expect = chai.expect;
6 |
7 | const renderComponent = require('./render-component');
8 |
9 | describe('render-component', function() {
10 | describe('renderJsxText', function() {
11 | it('should render simple text', function() {
12 | const node = { value: 'Hello World' };
13 | const res = renderComponent.renderJsxText(node, '');
14 | expect(res).to.equal('Hello World\n');
15 | });
16 |
17 | it('should use the given indent', function() {
18 | const node = { value: 'text' };
19 | const res = renderComponent.renderJsxText(node, ' ');
20 | expect(res).to.equal(' text\n');
21 | });
22 |
23 | it('should replace a simple binding', function() {
24 | const node = { value: '{{ text }}' };
25 | const res = renderComponent.renderJsxText(node, '');
26 | expect(res).to.equal('{ text }\n');
27 | });
28 |
29 | it('should replace a multiple bindings', function() {
30 | const node = { value: '{{ text }} {{ value }}' };
31 | const res = renderComponent.renderJsxText(node, '');
32 | expect(res).to.equal('{ text } { value }\n');
33 | });
34 |
35 | it('should keep original spaces', function() {
36 | const node = { value: ' {{ text }} {{ value }} ' };
37 | const res = renderComponent.renderJsxText(node, '');
38 | expect(res).to.equal(' { text } { value } \n');
39 | });
40 |
41 | it('should trim binding spaces', function() {
42 | const node = { value: '{{ text }}{{value }}' };
43 | const res = renderComponent.renderJsxText(node, '');
44 | expect(res).to.equal('{ text }{ value }\n');
45 | });
46 | });
47 |
48 | describe('renderJsxPropsSpreading', function() {
49 | it('should render binding', function() {
50 | const res = renderComponent.renderJsxPropsSpreading('{{ value }}');
51 | expect(res).to.equal('{ ...value }');
52 | });
53 |
54 | it('should ignore spaces', function() {
55 | const res = renderComponent.renderJsxPropsSpreading(' {{ value }} ');
56 | expect(res).to.equal('{ ...value }');
57 | });
58 | });
59 |
60 | describe('renderJsxProp', function() {
61 | it('should consider empty as `true`', function() {
62 | const res = renderComponent.renderJsxProp('', 'name');
63 | expect(res).to.equal('name={ true }');
64 | });
65 |
66 | [ 'true', 'false', 'TRUE', 'FALSE' ].forEach(test => {
67 | it(`should consider '${test}' as boolean`, function() {
68 | const res = renderComponent.renderJsxProp(test, 'name');
69 | expect(res).to.equal(`name={ ${test.toLowerCase()} }`);
70 | });
71 | });
72 |
73 | it('should render a strict binding', function() {
74 | const res = renderComponent.renderJsxProp('{{ value }}', 'name');
75 | expect(res).to.equal('name={ value }');
76 | });
77 |
78 | it('should ignore strict binding spaces', function() {
79 | const res = renderComponent.renderJsxProp(' {{ value}} ', 'name');
80 | expect(res).to.equal('name={ value }');
81 | });
82 |
83 | it('should render a binding', function() {
84 | const res = renderComponent.renderJsxProp('hello {{ name }}!', 'name');
85 | expect(res).to.equal('name={ `hello ${ name }!` }');
86 | });
87 |
88 | it('should ignore spaces in a binding', function() {
89 | const res = renderComponent.renderJsxProp(' hello {{name }}!', 'name');
90 | expect(res).to.equal('name={ ` hello ${ name }!` }');
91 | });
92 | });
93 |
94 | describe('renderJsxProps', function() {
95 | it('should render simple attribute', function() {
96 | const node = { attrs: { name: 'value' } };
97 | const res = renderComponent.renderJsxProps(node);
98 | expect(res).to.equal(` name='value'`);
99 | });
100 |
101 | it(`should convert the attribute 'for'`, function() {
102 | const node = { attrs: { for: 'value' } };
103 | const res = renderComponent.renderJsxProps(node);
104 | expect(res).to.equal(` htmlFor='value'`);
105 | });
106 |
107 | it(`should convert the attribute 'class'`, function() {
108 | const node = { attrs: { class: 'value' } };
109 | const res = renderComponent.renderJsxProps(node);
110 | expect(res).to.equal(` className='value'`);
111 | });
112 |
113 | it('should render attribute spreading', function() {
114 | const node = { attrs: { 'use-props': '{{ value }}' } };
115 | const res = renderComponent.renderJsxProps(node);
116 | expect(res).to.equal(` { ...value }`);
117 | });
118 |
119 | it('should render bindings', function() {
120 | const node = { attrs: { name: 'hello {{ name }}!' } };
121 | const res = renderComponent.renderJsxProps(node);
122 | expect(res).to.equal(' name={ `hello ${ name }!` }');
123 | });
124 |
125 | it('should render multiple attributes', function() {
126 | const node = {
127 | attrs: {
128 | name: 'value',
129 | toto: '{{ tata }}'
130 | }
131 | };
132 | const res = renderComponent.renderJsxProps(node);
133 | expect(res).to.equal(` name='value' toto={ tata }`);
134 | });
135 | });
136 |
137 | describe('renderJsxBasicTag', function() {
138 | it('should render a simple self closing tag', function() {
139 | const node = { name: 'name', attrs: {}, children: [] };
140 | const res = renderComponent.renderJsxBasicTag(node, {}, '');
141 | expect(res).to.equal(' \n');
142 | });
143 |
144 | it('should render a self closing tag with attributes', function() {
145 | const attrs = { toto: 'tata', plop: 'blop' };
146 | const node = { name: 'name', attrs, children: [] };
147 | const res = renderComponent.renderJsxBasicTag(node, {}, '');
148 | expect(res).to.equal(` \n`);
149 | });
150 |
151 | it('should render a tag with children', function() {
152 | const children = [{
153 | type: 'tag',
154 | name: 'child',
155 | attrs: {},
156 | children: []
157 | }];
158 | const node = { name: 'name', attrs: {}, children };
159 | const res = renderComponent.renderJsxBasicTag(node, {}, '');
160 | expect(res).to.equal(`\n \n \n`);
161 | });
162 |
163 | it('should convert the tag name', function() {
164 | const node = { name: 'name-to-convert', attrs: {}, children: [] };
165 | const tagToVar = { 'name-to-convert': 'nameToConvert' };
166 | const res = renderComponent.renderJsxBasicTag(node, tagToVar, '');
167 | expect(res).to.equal(` \n`);
168 | });
169 |
170 | it('should use the given indent', function() {
171 | const node = { name: 'name', attrs: {}, children: [] };
172 | const res = renderComponent.renderJsxBasicTag(node, {}, ' ');
173 | expect(res).to.equal(` \n`);
174 | });
175 | });
176 |
177 | describe('renderJsxConditionalTag', function() {
178 | const children = [{
179 | type: 'tag',
180 | name: 'name',
181 | attrs: {},
182 | children: []
183 | }];
184 |
185 | it('should render a simple conditional', function() {
186 | const node = { attrs: { if: '{{ test }}' }, children };
187 | const res = renderComponent.renderJsxConditionalTag(node, {}, '');
188 | expect(res).to.equal('{ (test) && (\n \n) }\n');
189 | });
190 |
191 | it('should ignore spaces in binding', function() {
192 | const node = { attrs: { if: ' {{ test }}' }, children };
193 | const res = renderComponent.renderJsxConditionalTag(node, {}, '');
194 | expect(res).to.equal('{ (test) && (\n \n) }\n');
195 | });
196 |
197 | it('should render composed conditionals', function() {
198 | const node = { attrs: { if: '{{ test && plop || toto }}' }, children };
199 | const res = renderComponent.renderJsxConditionalTag(node, {}, '');
200 | expect(res).to.equal('{ (test && plop || toto) && (\n \n) }\n');
201 | });
202 |
203 | it('should use the given indent', function() {
204 | const node = { attrs: { if: '{{ test }}' }, children };
205 | const res = renderComponent.renderJsxConditionalTag(node, {}, ' ');
206 | expect(res).to.equal(' { (test) && (\n \n ) }\n');
207 | });
208 | });
209 |
210 | describe('renderJsxLoopTag', function() {
211 | const children = [{
212 | type: 'tag',
213 | name: 'name',
214 | attrs: {},
215 | children: []
216 | }];
217 |
218 | it('should render a simple loop', function() {
219 | const attrs = { 'for-each': '{{ array }}', 'as': '{{ item }}' };
220 | const node = { attrs, children };
221 | const res = renderComponent.renderJsxLoopTag(node, {}, '');
222 | expect(res).to.equal('{ array.map(item => (\n \n)) }\n');
223 | });
224 |
225 | it('should ignore spaces in binding', function() {
226 | const attrs = { 'for-each': ' {{ array}}', 'as': '{{item }} ' };
227 | const node = { attrs, children };
228 | const res = renderComponent.renderJsxLoopTag(node, {}, '');
229 | expect(res).to.equal('{ array.map(item => (\n \n)) }\n');
230 | });
231 |
232 | it('should use the given indent', function() {
233 | const attrs = { 'for-each': ' {{ array}}', 'as': '{{item }} ' };
234 | const node = { attrs, children };
235 | const res = renderComponent.renderJsxLoopTag(node, {}, ' ');
236 | expect(res).to.equal(' { array.map(item => (\n \n )) }\n');
237 | });
238 | });
239 | });
240 |
--------------------------------------------------------------------------------
/lib/attribute-conversion.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const MAP = {
4 | // HTML
5 | 'accesskey': 'accessKey',
6 | 'allowfullscreen': 'allowFullScreen',
7 | 'allowtransparency': 'allowTransparency',
8 | 'autocomplete': 'autoComplete',
9 | 'autofocus': 'autoFocus',
10 | 'autoplay': 'autoPlay',
11 | 'cellpadding': 'cellPadding',
12 | 'cellspacing': 'cellSpacing',
13 | 'charset': 'charSet',
14 | 'classid': 'classID',
15 | 'colspan': 'colSpan',
16 | 'contenteditable': 'contentEditable',
17 | 'contextmenu': 'contextMenu',
18 | 'crossorigin': 'crossOrigin',
19 | 'datetime': 'dateTime',
20 | 'enctype': 'encType',
21 | 'formaction': 'formAction',
22 | 'formenctype': 'formEncType',
23 | 'formmethod': 'formMethod',
24 | 'formnovalidate': 'formNoValidate',
25 | 'formtarget': 'formTarget',
26 | 'frameborder': 'frameBorder',
27 | 'hreflang': 'hrefLang',
28 | 'inputmode': 'inputMode',
29 | 'keyparams': 'keyParams',
30 | 'keytype': 'keyType',
31 | 'marginheight': 'marginHeight',
32 | 'marginwidth': 'marginWidth',
33 | 'maxlength': 'maxLength',
34 | 'mediagroup': 'mediaGroup',
35 | 'minlength': 'minLength',
36 | 'novalidate': 'noValidate',
37 | 'radiogroup': 'radioGroup',
38 | 'readonly': 'readOnly',
39 | 'rowspan': 'rowSpan',
40 | 'spellcheck': 'spellCheck',
41 | 'srcdoc': 'srcDoc',
42 | 'srclang': 'srcLang',
43 | 'srcset': 'srcSet',
44 | 'tabindex': 'tabIndex',
45 | 'usemap': 'useMap',
46 |
47 | // SVG
48 | 'accentheight': 'accentHeight',
49 | 'alignmentbaseline': 'alignmentBaseline',
50 | 'allowreorder': 'allowReorder',
51 | 'arabicform': 'arabicForm',
52 | 'attributename': 'attributeName',
53 | 'attributetype': 'attributeType',
54 | 'autoreverse': 'autoReverse',
55 | 'basefrequency': 'baseFrequency',
56 | 'baselineshift': 'baselineShift',
57 | 'baseprofile': 'baseProfile',
58 | 'calcmode': 'calcMode',
59 | 'capheight': 'capHeight',
60 | 'clippath': 'clipPath',
61 | 'clippathunits': 'clipPathUnits',
62 | 'cliprule': 'clipRule',
63 | 'colorinterpolation': 'colorInterpolation',
64 | 'colorinterpolationfilters': 'colorInterpolationFilters',
65 | 'colorprofile': 'colorProfile',
66 | 'colorrendering': 'colorRendering',
67 | 'contentscripttype': 'contentScriptType',
68 | 'contentstyletype': 'contentStyleType',
69 | 'diffuseconstant': 'diffuseConstant',
70 | 'dominantbaseline': 'dominantBaseline',
71 | 'edgemode': 'edgeMode',
72 | 'enablebackground': 'enableBackground',
73 | 'externalresourcesrequired': 'externalResourcesRequired',
74 | 'fillopacity': 'fillOpacity',
75 | 'fillrule': 'fillRule',
76 | 'filterres': 'filterRes',
77 | 'filterunits': 'filterUnits',
78 | 'floodcolor': 'floodColor',
79 | 'floodopacity': 'floodOpacity',
80 | 'fontfamily': 'fontFamily',
81 | 'fontsize': 'fontSize',
82 | 'fontsizeadjust': 'fontSizeAdjust',
83 | 'fontstretch': 'fontStretch',
84 | 'fontstyle': 'fontStyle',
85 | 'fontvariant': 'fontVariant',
86 | 'fontweight': 'fontWeight',
87 | 'glyphname': 'glyphName',
88 | 'glyphorientationhorizontal': 'glyphOrientationHorizontal',
89 | 'glyphorientationvertical': 'glyphOrientationVertical',
90 | 'glyphref': 'glyphRef',
91 | 'gradienttransform': 'gradientTransform',
92 | 'gradientunits': 'gradientUnits',
93 | 'horizadvx': 'horizAdvX',
94 | 'horizoriginx': 'horizOriginX',
95 | 'imagerendering': 'imageRendering',
96 | 'kernelmatrix': 'kernelMatrix',
97 | 'kernelunitlength': 'kernelUnitLength',
98 | 'keypoints': 'keyPoints',
99 | 'keysplines': 'keySplines',
100 | 'keytimes': 'keyTimes',
101 | 'lengthadjust': 'lengthAdjust',
102 | 'letterspacing': 'letterSpacing',
103 | 'lightingcolor': 'lightingColor',
104 | 'limitingconeangle': 'limitingConeAngle',
105 | 'markerend': 'markerEnd',
106 | 'markerheight': 'markerHeight',
107 | 'markermid': 'markerMid',
108 | 'markerstart': 'markerStart',
109 | 'markerunits': 'markerUnits',
110 | 'markerwidth': 'markerWidth',
111 | 'maskcontentunits': 'maskContentUnits',
112 | 'maskunits': 'maskUnits',
113 | 'numoctaves': 'numOctaves',
114 | 'overlineposition': 'overlinePosition',
115 | 'overlinethickness': 'overlineThickness',
116 | 'paintorder': 'paintOrder',
117 | 'pathlength': 'pathLength',
118 | 'patterncontentunits': 'patternContentUnits',
119 | 'patterntransform': 'patternTransform',
120 | 'patternunits': 'patternUnits',
121 | 'pointerevents': 'pointerEvents',
122 | 'pointsatx': 'pointsAtX',
123 | 'pointsaty': 'pointsAtY',
124 | 'pointsatz': 'pointsAtZ',
125 | 'preservealpha': 'preserveAlpha',
126 | 'preserveaspectratio': 'preserveAspectRatio',
127 | 'primitiveunits': 'primitiveUnits',
128 | 'refx': 'refX',
129 | 'refy': 'refY',
130 | 'renderingintent': 'renderingIntent',
131 | 'repeatcount': 'repeatCount',
132 | 'repeatdur': 'repeatDur',
133 | 'requiredextensions': 'requiredExtensions',
134 | 'requiredfeatures': 'requiredFeatures',
135 | 'shaperendering': 'shapeRendering',
136 | 'specularconstant': 'specularConstant',
137 | 'specularexponent': 'specularExponent',
138 | 'spreadmethod': 'spreadMethod',
139 | 'startoffset': 'startOffset',
140 | 'stddeviation': 'stdDeviation',
141 | 'stitchtiles': 'stitchTiles',
142 | 'stopcolor': 'stopColor',
143 | 'stopopacity': 'stopOpacity',
144 | 'strikethroughposition': 'strikethroughPosition',
145 | 'strikethroughthickness': 'strikethroughThickness',
146 | 'strokedasharray': 'strokeDasharray',
147 | 'strokedashoffset': 'strokeDashoffset',
148 | 'strokelinecap': 'strokeLinecap',
149 | 'strokelinejoin': 'strokeLinejoin',
150 | 'strokemiterlimit': 'strokeMiterlimit',
151 | 'strokeopacity': 'strokeOpacity',
152 | 'strokewidth': 'strokeWidth',
153 | 'surfacescale': 'surfaceScale',
154 | 'systemlanguage': 'systemLanguage',
155 | 'tablevalues': 'tableValues',
156 | 'targetx': 'targetX',
157 | 'targety': 'targetY',
158 | 'textanchor': 'textAnchor',
159 | 'textdecoration': 'textDecoration',
160 | 'textlength': 'textLength',
161 | 'textrendering': 'textRendering',
162 | 'underlineposition': 'underlinePosition',
163 | 'underlinethickness': 'underlineThickness',
164 | 'unicodebidi': 'unicodeBidi',
165 | 'unicoderange': 'unicodeRange',
166 | 'unitsperem': 'unitsPerEm',
167 | 'valphabetic': 'vAlphabetic',
168 | 'vectoreffect': 'vectorEffect',
169 | 'vertadvy': 'vertAdvY',
170 | 'vertoriginx': 'vertOriginX',
171 | 'vertoriginy': 'vertOriginY',
172 | 'vhanging': 'vHanging',
173 | 'videographic': 'vIdeographic',
174 | 'viewbox': 'viewBox',
175 | 'viewtarget': 'viewTarget',
176 | 'vmathematical': 'vMathematical',
177 | 'wordspacing': 'wordSpacing',
178 | 'writingmode': 'writingMode',
179 | 'xchannelselector': 'xChannelSelector',
180 | 'xheight': 'xHeight',
181 | 'xlinkactuate': 'xlinkActuate',
182 | 'xlinkarcrole': 'xlinkArcrole',
183 | 'xlinkhref': 'xlinkHref',
184 | 'xlinkrole': 'xlinkRole',
185 | 'xlinkshow': 'xlinkShow',
186 | 'xlinktitle': 'xlinkTitle',
187 | 'xlinktype': 'xlinkType',
188 | 'xmlbase': 'xmlBase',
189 | 'xmllang': 'xmlLang',
190 | 'xmlspace': 'xmlSpace',
191 | 'ychannelselector': 'yChannelSelector',
192 | 'zoomandpan': 'zoomAndPan',
193 |
194 | // Special HTML
195 | 'accept-charset': 'acceptCharset',
196 | 'class': 'className',
197 | 'for': 'htmlFor',
198 | 'http-equiv': 'httpEquiv',
199 |
200 | // React events
201 | 'on-copy': 'onCopy',
202 | 'on-cut': 'onCut',
203 | 'on-paste': 'onPaste',
204 | 'on-composition-end': 'onCompositionEnd',
205 | 'on-composition-start': 'onCompositionStart',
206 | 'on-composition-update': 'onCompositionUpdate',
207 | 'on-key-down': 'onKeyDown',
208 | 'on-key-press': 'onKeyPress',
209 | 'on-key-up': 'onKeyUp',
210 | 'on-focus': 'onFocus',
211 | 'on-blur': 'onBlur',
212 | 'on-change': 'onChange',
213 | 'on-input': 'onInput',
214 | 'on-submit': 'onSubmit',
215 | 'on-click': 'onClick',
216 | 'on-context-menu': 'onContextMenu',
217 | 'on-double-click': 'onDoubleClick',
218 | 'on-drag': 'onDrag',
219 | 'on-drag-end': 'onDragEnd',
220 | 'on-drag-enter': 'onDragEnter',
221 | 'on-drag-exit': 'onDragExit',
222 | 'on-drag-leave': 'onDragLeave',
223 | 'on-drag-over': 'onDragOver',
224 | 'on-drag-start': 'onDragStart',
225 | 'on-drop': 'onDrop',
226 | 'on-mouse-down': 'onMouseDown',
227 | 'on-mouse-enter': 'onMouseEnter',
228 | 'on-mouse-leave': 'onMouseLeave',
229 | 'on-mouse-move': 'onMouseMove',
230 | 'on-mouse-out': 'onMouseOut',
231 | 'on-mouse-over': 'onMouseOver',
232 | 'on-mouse-up': 'onMouseUp',
233 | 'on-select': 'onSelect',
234 | 'on-touch-cancel': 'onTouchCancel',
235 | 'on-touch-end': 'onTouchEnd',
236 | 'on-touch-move': 'onTouchMove',
237 | 'on-touch-start': 'onTouchStart',
238 | 'on-scroll': 'onScroll',
239 | 'on-wheel': 'onWheel',
240 | 'on-abort': 'onAbort',
241 | 'on-can-play': 'onCanPlay',
242 | 'on-can-play-through': 'onCanPlayThrough',
243 | 'on-duration-change': 'onDurationChange',
244 | 'on-emptied': 'onEmptied',
245 | 'on-encrypted': 'onEncrypted',
246 | 'on-ended': 'onEnded',
247 | 'on-error': 'onError',
248 | 'on-loaded-data': 'onLoadedData',
249 | 'on-loaded-meta-data': 'onLoadedMetadata',
250 | 'on-loaded-start': 'onLoadStart',
251 | 'on-pause': 'onPause',
252 | 'on-play': 'onPlay',
253 | 'on-playing': 'onPlaying',
254 | 'on-progress': 'onProgress',
255 | 'on-rate-change': 'onRateChange',
256 | 'on-seeked': 'onSeeked',
257 | 'on-seeking': 'onSeeking',
258 | 'on-stalled': 'onStalled',
259 | 'on-suspend': 'onSuspend',
260 | 'on-time-update': 'onTimeUpdate',
261 | 'on-volume-change': 'onVolumeChange',
262 | 'on-waiting': 'onWaiting',
263 | 'on-load': 'onLoad',
264 | 'on-animation-start': 'onAnimationStart',
265 | 'on-animation-end': 'onAnimationEnd',
266 | 'on-animation-iteration': 'onAnimationIteration',
267 | 'on-transition-end': 'onTransitionEnd'
268 | };
269 |
270 | function toJsx(html) {
271 | return MAP[html] || html;
272 | }
273 |
274 | module.exports = {
275 | toJsx
276 | };
277 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # react-pure-html-component-loader
2 |
3 | [](https://travis-ci.org/simonrelet/react-pure-html-component-loader) [](https://badge.fury.io/js/react-pure-html-component-loader)
4 |
5 | > A Webpack loader allowing imports of HTML components as if they were React
6 | > pure functional components.
7 |
8 | ## Usage
9 |
10 | _./click-me-view.jsx.html_
11 | ```html
12 |
13 |
14 | Clicked {{ props.clicks }} time(s)
15 |
16 |
17 | ```
18 |
19 | _./click-me-container.jsx_
20 | ```js
21 | import React, { Component } from 'react';
22 |
23 | // Import the HTML component as if it was a React component.
24 | import ClickMeView from './click-me-view';
25 |
26 | export default class ClickMeContainer extends Component {
27 | constructor(props) {
28 | super(props);
29 |
30 | this.state = { clicks: 0 };
31 | this.buttonProps = { onMouseDown: this.handleMouseDown.bind(this) };
32 | }
33 |
34 | handleMouseDown(e) {
35 | e.preventDefault();
36 | this.setState({ clicks: this.state.clicks + 1 });
37 | }
38 |
39 | render() {
40 | return (
41 |
45 | );
46 | }
47 | }
48 | ```
49 |
50 | Add the react-pure-html-component-loader to your _webpack.config.js_:
51 | ```js
52 | {
53 | module: {
54 | loaders: [
55 | {
56 | test: /\.jsx\.html$/,
57 | exclude: /node_modules/,
58 | loader: 'babel!react-pure-html-component'
59 | }
60 | ]
61 | },
62 | resolve: {
63 | extensions: [ '.jsx', '.jsx.html' ]
64 | }
65 | }
66 | ```
67 |
68 | ## Supported Features
69 |
70 | * Default and named imports/exports,
71 | * Multiple component definitions in the same file,
72 | * Explicit conditional and loop rendering,
73 | * Props spreading,
74 | * CSS modules.
75 |
76 | ## Installation
77 |
78 | ```
79 | npm install --save-dev react-pure-html-component-loader
80 | ```
81 |
82 | ## Background
83 |
84 | React provides a great developing experience, you finally have a strong
85 | integration between the JavaScript code and the template syntax, it feels
86 | natural to write.
87 |
88 | But this merge isn't that good for designers who just know enough HTML and,
89 | depending on the requirements, it can be a disqualifying criteria for React.
90 |
91 | Thanks to the pure functional components and the [Presentational and Container
92 | pattern](https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0#.b4xio9vw9),
93 | most components are simply _templates_ having data as input and some UI
94 | as output. What if those pure functional components could simply be written in
95 | HTML to be easily created and modified by designers?
96 |
97 | The purpose of this Webpack loader is to convert **HTML components to React pure
98 | functional component**.
99 |
100 | **react-pure-html-component-loader** reconcile developers and designers. It is a
101 | Webpack loader compiling HTML components into pure functional React components.
102 |
103 | ## Demo
104 |
105 | Some demos can be found under the `demo/` folder, to launch one type in a
106 | console:
107 |
108 | ```
109 | npm run demo --
110 | ```
111 |
112 | _For example:_
113 |
114 | ```
115 | npm run demo -- demo/todo-list
116 | ```
117 |
118 | ## API
119 |
120 | ### Imports
121 |
122 | #### Component import
123 |
124 | ##### Default import
125 |
126 | Import the default component of a file.
127 |
128 | **Usage**
129 | ```html
130 |
131 | ```
132 |
133 | **Attributes**
134 | * `rel`: Must be set to `import` for this kind of relation,
135 | * `href`: Path of the file to import,
136 | * `id`: Name to use to reference the default component of the file.
137 |
138 | **Example**
139 | ```html
140 |
141 | ```
142 |
143 | _Is equivalent in ES2015 to:_
144 | ```js
145 | import MyComponent from 'path/to/component';
146 | ```
147 |
148 | ##### Named imports
149 |
150 | Import a component by its name. The ` ` tag for a named import must be
151 | child of another ` ` tag having a `href` attribute.
152 |
153 | **Usage**
154 | ```html
155 |
156 |
157 |
158 | ```
159 |
160 | **Attributes**
161 | * `rel`: Must be set to `import` for this kind of relation,
162 | * `href`: Path of the file to import,
163 | * `name` _(Optional)_: Name of the component to import, can be omitted if it
164 | is the same as `id`,
165 | * `id`: Name to use to reference the component.
166 |
167 | **Example**
168 | ```html
169 |
170 |
171 |
172 |
173 | ```
174 |
175 | _Is equivalent in ES2015 to:_
176 | ```js
177 | import {
178 | ComponentOne,
179 | ComponentTwo as ComponentAlias
180 | } from 'path/to/component';
181 | ```
182 |
183 | ##### Default and named imports
184 |
185 | Import the default and some named components from the same file.
186 |
187 | **Usage**
188 | ```html
189 |
190 |
191 |
192 | ```
193 |
194 | **Attributes**
195 | * _See [default imports](#default-import) and [named imports](#named-imports)._
196 |
197 | **Example**
198 | ```html
199 |
200 |
201 |
202 |
203 | ```
204 |
205 | _Is equivalent in ES2015 to:_
206 | ```js
207 | import MyComponent, {
208 | ComponentOne,
209 | ComponentTwo as ComponentAlias
210 | } from 'path/to/component';
211 | ```
212 |
213 | #### Stylesheet import (CSS Modules)
214 |
215 | ##### Global stylesheet
216 |
217 | Import a global stylesheet.
218 |
219 | **Usage**
220 | ```html
221 |
222 | ```
223 |
224 | **Attributes**
225 | * `rel`: Must be set to `stylesheet` for this kind of relation,
226 | * `href`: Path of the file to import.
227 |
228 | **Example**
229 | ```html
230 |
231 | ```
232 |
233 | _Is equivalent in ES2015 to:_
234 | ```js
235 | import './global-style';
236 | ```
237 |
238 | ##### Named stylesheet
239 |
240 | Import a stylesheet and name it.
241 |
242 | **Usage**
243 | ```html
244 |
245 | ```
246 |
247 | **Attributes**
248 | * `rel`: Must be set to `stylesheet` for this kind of relation,
249 | * `href`: Path of the file to import,
250 | * `id`: Value to use to reference the stylesheet.
251 |
252 | **Example**
253 | ```html
254 |
255 | ```
256 |
257 | _Is equivalent in ES2015 to:_
258 | ```js
259 | import style from './style';
260 | ```
261 |
262 | _It can be used this way:_
263 | ```html
264 |
265 | ```
266 |
267 | ### Components
268 |
269 | A component is the content of an HTML tag ``. It can only have a
270 | single child.
271 |
272 | #### Default component
273 |
274 | Each file must contain at most one default component. A default component is the
275 | main component of the file.
276 |
277 | **Usage**
278 | ```html
279 |
280 |
281 |
282 | ```
283 |
284 | **Attributes**
285 | * `default`: Flag the component as default,
286 | * `id` _(Optional)_: Tag name to use to reference this component. Also used
287 | to set the `displayName` of the component for debug purpose.
288 |
289 | **Example**
290 | ```html
291 |
292 | Hello World
293 |
294 | ```
295 |
296 | _Is equivalent in React to:_
297 | ```js
298 | export default function HelloWorld() {
299 | return (
300 | Hello World
301 | );
302 | }
303 | HelloWorld.displayName = 'HelloWorld';
304 | ```
305 |
306 | #### Named components
307 |
308 | A named component is simply a `` tag with an `id` attribute, which
309 | means it can be used by referencing its name. All named components will be
310 | exported under their given name.
311 |
312 | **Usage**
313 | ```html
314 |
315 |
316 |
317 | ```
318 |
319 | **Attributes**
320 | * `id`: Tag name to use to reference this component. Also used
321 | to set the `displayName` of the component for debug purpose.
322 |
323 | **Example**
324 | ```html
325 |
326 |
327 |
328 |
329 |
330 |
331 |
332 |
333 |
334 | ```
335 |
336 | _Is equivalent in React to:_
337 | ```js
338 | export function NamedComponent(props) {
339 | return (
340 | // ...
341 | );
342 | }
343 | NamedComponent.displayName = 'NamedComponent';
344 |
345 | export default function(props) {
346 | return (
347 | // ...
348 |
349 | // ...
350 | );
351 | }
352 | ```
353 |
354 | ### Loops
355 |
356 | A loop will render its content for each element in the array. The `render` tag
357 | can only have one child. When looping over an array, a `key` attribute must be
358 | set on each child tag.
359 |
360 | **Usage**
361 | ```html
362 |
363 |
364 |
365 | ```
366 |
367 | **Attributes**
368 | * `for-each`: Array of data,
369 | * `as`: Name of the variable to use for each element.
370 |
371 | **Example**
372 | ```html
373 |
374 |
375 |
376 | {{ user.name }}
377 |
378 |
379 |
380 | ```
381 |
382 | _Is equivalent in React to:_
383 | ```js
384 | export default function(props) {
385 | return (
386 |
387 | { props.users.map(user => (
388 |
389 | { user.name }
390 |
391 | )) }
392 |
393 | );
394 | }
395 | ```
396 |
397 | ### Conditionals
398 |
399 | A conditional will render its content depending on a condition. The `render` tag
400 | can only have one child.
401 |
402 | **Usage**
403 | ```html
404 |
405 |
406 |
407 | ```
408 |
409 | **Attributes**
410 | * `if`: Condition to fulfill for the content to be rendered.
411 |
412 | **Example**
413 | ```html
414 |
415 |
416 |
417 | {{ props.user.name }}
418 |
419 |
420 |
421 | ```
422 |
423 | _Is equivalent in React to:_
424 | ```js
425 | export default function(props) {
426 | return (
427 |
428 | { props.user &&
{ props.user.name }
}
429 |
430 | );
431 | }
432 | ```
433 |
434 | ### Props spreading
435 |
436 | Props spreading is used to simplify the component so the focus can be kept on
437 | the UI.
438 |
439 | **Usage**
440 | ```html
441 |
442 |
443 |
444 | ```
445 |
446 | **Attributes**
447 | * `use-props`: Variable that will be spread in the corresponding tag.
448 |
449 | **Example**
450 |
451 | _Instead of writing:_
452 | ```html
453 |
454 |
460 | Clicked {{ props.clicks }} time(s)
461 |
462 |
463 | ```
464 |
465 | _Just write:_
466 | ```html
467 |
468 |
469 | Clicked {{ props.clicks }} time(s)
470 |
471 |
472 | ```
473 |
474 | _Which is equivalent in React to:_
475 | ```js
476 | export default function(props) {
477 | return (
478 |
479 | Clicked { props.clicks } time(s)
480 |
481 | );
482 | }
483 | ```
484 |
485 | The conversion from HTML attributes to JSX can be found in this [mapping file](lib/attribute-conversion.js).
486 |
487 | # License
488 |
489 | MIT.
490 |
--------------------------------------------------------------------------------