diff --git a/.eslintrc.js b/.eslintrc.js index 2182aeba..52c39c47 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -20,7 +20,8 @@ module.exports = { afterEach: true, afterAll: true, expect: true, - jasmine: true + jasmine: true, + jest: true }, parser: 'babel-eslint' } diff --git a/packages/nerv-server/__tests__/render.js b/packages/nerv-server/__tests__/render.js new file mode 100644 index 00000000..78dfea37 --- /dev/null +++ b/packages/nerv-server/__tests__/render.js @@ -0,0 +1,267 @@ +/** @jsx createElement */ +import { Component, createElement } from 'nervjs' +import { renderToString } from '../src' +const render = renderToString +describe('render', () => { + describe('Basic JSX', () => { + it('should render JSX', () => { + const rendered = render(
bar
) + const expected = `
bar
` + + expect(rendered).toEqual(expected) + }) + + it('should omit falsey attributes', () => { + const rendered = render(
) + const expected = `
` + + expect(rendered).toEqual(expected) + + expect(render(
)).toEqual(`
`) + }) + + it('should collapse collapsible attributes', () => { + const rendered = render(
) + const expected = `
` + + expect(rendered).toEqual(expected) + }) + + it('should encode entities', () => { + const rendered = render(
&'}>{'"<>&'}
) + const expected = `
"<>&
` + + expect(rendered).toEqual(expected) + }) + + it('should self-close void elements', () => { + const rendered = render( +
+ + +
+ ) + const expected = `
` + + expect(rendered).toEqual(expected) + }) + + it('does not close void elements with closing tags', () => { + const rendered = render( + +

Hello World

+ + ) + const expected = `` + + expect(rendered).toEqual(expected) + }) + + it('should serialize object styles', () => { + const rendered = render(
) + const expected = `
` + + expect(rendered).toEqual(expected) + }) + + it('should ignore empty object styles', () => { + const rendered = render(
) + const expected = `
` + + expect(rendered).toEqual(expected) + }) + + // FIX: SVG problem + // it('should render SVG elements', () => { + // const rendered = render(( + // + // + // + //
+ // + // + // + // + // + // )) + + // tslint:disable-next-line:max-line-length + // expect(rendered).toEqual(`
`) + // }) + }) + + describe('Functional component', () => { + it('should render functional components', () => { + const Test = jest.fn(({ foo, children }) => ( +
{children}
+ )) + const rendered = render(content) + expect(rendered).toEqual(`
content
`) + expect(Test).toBeCalled() + expect(Test).toBeCalledWith({ children: ['content'], foo: 'test' }, {}) + }) + + it('should render functional components within JSX', () => { + const Test = jest.fn(({ foo, children }) => ( +
{children}
+ )) + const rendered = render( +
+ + asdf + +
+ ) + expect(rendered).toEqual( + `
asdf
` + ) + expect(Test).toHaveBeenCalled() + expect(Test).toHaveBeenCalledWith( + { + foo: 1, + children: [asdf] + }, + {} + ) + }) + }) + + describe('Classical Component', () => { + it('should render classical components', () => { + class Test extends Component { + render () { + const { foo, children } = this.props + return
{children}
+ } + } + + const spy = jest.spyOn(Test.prototype, 'render') + const widget = content + const rendered = render(widget) + expect(rendered).toEqual(`
content
`) + expect(spy).toHaveBeenCalledTimes(1) + }) + + it('should render classical components within JSX', () => { + class Test extends Component { + render () { + const { foo, children } = this.props + return
{children}
+ } + } + const spy = jest.spyOn(Test.prototype, 'render') + const rendered = render( +
+ + asdf + +
+ ) + expect(rendered).toEqual( + `
asdf
` + ) + expect(spy).toHaveBeenCalledTimes(1) + }) + + it('should apply defaultProps', () => { + class Test extends Component { + render () { + return
+ } + } + Test.defaultProps = { + foo: 'default foo', + bar: 'default bar' + } + + expect(render()).toEqual( + '
' + ) + expect(render()).toEqual( + '
' + ) + expect(render()).toEqual( + '
' + ) + }) + + it('should invoke componentWillMount', () => { + class Test extends Component { + // tslint:disable-next-line:no-empty + componentWillMount () {} + render () { + return
+ } + } + const spy = jest.spyOn(Test.prototype, 'componentWillMount') + render() + expect(spy).toHaveBeenCalledTimes(1) + }) + + it('should pass context to direct child', () => { + const CONTEXT = { a: 'a' } + class Outer extends Component { + getChildContext () { + return CONTEXT + } + render () { + return ( +
+ +
+ ) + } + } + const outerContextSpy = jest.spyOn(Outer.prototype, 'getChildContext') + + class Inner extends Component { + render () { + return
{this.context && this.context.a}
+ } + } + + const rendered = render() + + expect(rendered).toEqual(`
a
`) + expect(outerContextSpy).toHaveBeenCalledTimes(1) + }) + + it('should pass context to grandchildren', () => { + const CONTEXT = { a: 'a' } + class Outer extends Component { + getChildContext () { + return CONTEXT + } + render () { + return ( +
+ +
+ ) + } + } + const outerContextSpy = jest.spyOn(Outer.prototype, 'getChildContext') + + class Inner extends Component { + render () { + return ( +
+ +
+ ) + } + } + + class Child extends Component { + render () { + return
{this.context && this.context.a}
+ } + } + + const rendered = render() + + expect(rendered).toEqual(`
a
`) + expect(outerContextSpy).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/packages/nerv-server/package.json b/packages/nerv-server/package.json index 2f1b82d8..844a926e 100644 --- a/packages/nerv-server/package.json +++ b/packages/nerv-server/package.json @@ -4,9 +4,9 @@ "description": "Server-side rendering for Nerv.js", "author": "yuche", "license": "MIT", - "main": "dist/nerv.ssr.js", - "module": "dist/nerv.ssr.esm.js", - "jsnext:main": "dist/nerv.ssr.esm.js", + "main": "dist/index.js", + "module": "dist/nerv.ssr.js", + "jsnext:main": "dist/nerv.ssr.js", "types": "dist/index.d.ts", "repository": { "type": "git", diff --git a/packages/nerv-server/rollup.config.js b/packages/nerv-server/rollup.config.js new file mode 100644 index 00000000..a5b21dcc --- /dev/null +++ b/packages/nerv-server/rollup.config.js @@ -0,0 +1,17 @@ +const typescript = require('rollup-plugin-typescript2') +const pkg = require('./package.json') + +module.exports = { + input: 'src/index.ts', + plugins: [typescript()], + output: [ + { + format: 'cjs', + file: pkg.main + }, + { + format: 'es', + file: pkg.module + } + ] +} diff --git a/packages/nerv-server/src/index.ts b/packages/nerv-server/src/index.ts new file mode 100644 index 00000000..7bf93dcd --- /dev/null +++ b/packages/nerv-server/src/index.ts @@ -0,0 +1,166 @@ +// tslint:disable-next-line:max-line-length +import { + isVNode, + isVText, + isWidget, + isStateLess, + isString, + isNumber, + isFunction, + isNullOrUndef, + isArray, + isInvalid +} from './is' +import { + encodeEntities, + isVoidElements, + escapeText, + getCssPropertyName, + isUnitlessNumber, + assign +} from './utils' + +const skipAttributes = { + ref: true, + key: true, + children: true +} + +function hashToClassName (obj) { + const arr: string[] = [] + for (const i in obj) { + if (obj[i]) { + arr.push(i) + } + } + return arr.join(' ') +} + +function renderStylesToString (styles: string | object): string { + if (isString(styles)) { + return styles + } else { + let renderedString = '' + for (const styleName in styles) { + const value = styles[styleName] + + if (isString(value)) { + renderedString += `${getCssPropertyName(styleName)}${value};` + } else if (isNumber(value)) { + renderedString += `${getCssPropertyName( + styleName + )}${value}${isUnitlessNumber[styleName] ? '' : 'px'};` + } + } + return renderedString + } +} + +function renderVNodeToString (vnode, parent, context, firstChild) { + const { tagName, props, children } = vnode + if (isVText(vnode)) { + return encodeEntities(vnode.text) + } else if (isVNode(vnode)) { + let renderedString = `<${tagName}` + let html + if (!isNullOrUndef(props)) { + for (const prop in props) { + const value = props[prop] + if (skipAttributes[prop]) { + continue + } + if (prop === 'dangerouslySetInnerHTML') { + html = value.__html + } else if (prop === 'style') { + renderedString += ` style="${renderStylesToString(value)}"` + } else if (prop === 'class' || prop === 'className') { + renderedString += ` class="${isString(value) ? value : hashToClassName(value)}"` + } else if (prop === 'defaultValue') { + if (!props.value) { + renderedString += ` value="${escapeText(value)}"` + } + } else if (prop === 'defaultChecked') { + if (!props.checked) { + renderedString += ` checked="${value}"` + } + } else { + if (isString(value)) { + renderedString += ` ${prop}="${escapeText(value)}"` + } else if (isNumber(value)) { + renderedString += ` ${prop}="${value}"` + } else if (value === true) { + renderedString += ` ${prop}` + } + } + } + } + if (isVoidElements[tagName]) { + renderedString += `/>` + } else { + renderedString += `>` + if (!isInvalid(children)) { + if (isString(children)) { + renderedString += children === '' ? ' ' : escapeText(children) + } else if (isNumber(children)) { + renderedString += children + '' + } else if (isArray(children)) { + for (let i = 0, len = children.length; i < len; i++) { + const child = children[i] + if (isString(child)) { + renderedString += child === '' ? ' ' : escapeText(child) + } else if (isNumber(child)) { + renderedString += child + } else if (!isInvalid(child)) { + renderedString += renderVNodeToString( + child, + vnode, + context, + i === 0 + ) + } + } + } else { + renderedString += renderVNodeToString(children, vnode, context, true) + } + } else if (html) { + renderedString += html + } + if (!isVoidElements[tagName]) { + renderedString += `` + } + } + return renderedString + } else if (isWidget(vnode)) { + const { ComponentType: type } = vnode + const instance = new type(props, context) + instance._disable = true + if (isFunction(instance.getChildContext)) { + context = assign(assign({}, context), instance.getChildContext()) + } + instance.context = context + if (isFunction(instance.componentWillMount)) { + instance.componentWillMount() + } + const nextVnode = instance.render(props, instance.state, context) + + if (isInvalid(nextVnode)) { + return '' + } + return renderVNodeToString(nextVnode, vnode, context, true) + } else if (isStateLess(vnode)) { + const nextVnode = tagName(props, context) + + if (isInvalid(nextVnode)) { + return '' + } + return renderVNodeToString(nextVnode, vnode, context, true) + } +} + +export function renderToString (input: any): string { + return renderVNodeToString(input, {}, {}, true) as string +} + +export function renderToStaticMarkup (input: any): string { + return renderVNodeToString(input, {}, {}, true) as string +} diff --git a/packages/nerv-server/src/is.ts b/packages/nerv-server/src/is.ts new file mode 100644 index 00000000..022cb793 --- /dev/null +++ b/packages/nerv-server/src/is.ts @@ -0,0 +1,49 @@ +export function isVNode (node) { + return node && node.type === 'VirtualNode' +} + +export function isVText (node) { + return node && node.type === 'VirtualText' +} + +export function isWidget (node) { + return node && node.type === 'Widget' +} + +export function isStateLess (node) { + return node && node.type === 'StateLess' +} + +export function isNumber (arg): arg is number { + return typeof arg === 'number' +} + +export function isString (arg): arg is string { + return typeof arg === 'string' +} + +export function isFunction (arg): arg is Function { + return typeof arg === 'function' +} + +export function isBoolean (arg): arg is true | false { + return arg === true || arg === false +} + +export function isNullOrUndef (o: any): o is undefined | null { + return isUndefined(o) || isNull(o) +} + +export function isUndefined (o: any): o is undefined { + return o === void 0 +} + +export function isNull (o: any): o is null { + return o === null +} + +export function isInvalid (o: any): o is null | false | true | undefined { + return isNull(o) || o === false || o === true || isUndefined(o) +} + +export const isArray = Array.isArray diff --git a/packages/nerv-server/src/utils.ts b/packages/nerv-server/src/utils.ts new file mode 100644 index 00000000..267416dc --- /dev/null +++ b/packages/nerv-server/src/utils.ts @@ -0,0 +1,125 @@ +export function escapeText (text: string): string { + let result = '' + let escape = '' + let start = 0 + let i + for (i = 0; i < text.length; i++) { + switch (text.charCodeAt(i)) { + case 34: // " + escape = '"' + break + case 39: // \ + escape = ''' + break + case 38: // & + escape = '&' + break + case 60: // < + escape = '<' + break + case 62: // > + escape = '>' + break + default: + continue + } + if (i > start) { + if (start) { + result += text.slice(start, i) + } else { + result = text.slice(start, i) + } + } + result += escape + start = i + 1 + } + return result + text.slice(start, i) +} + +const uppercasePattern = /[A-Z]/g + +const CssPropCache = {} + +export function getCssPropertyName (str): string { + if (CssPropCache.hasOwnProperty(str)) { + return CssPropCache[str] + } + return (CssPropCache[str] = + str.replace(uppercasePattern, '-$&').toLowerCase() + ':') +} + +export const isVoidElements = { + 'area': true, + 'base': true, + 'br': true, + 'col': true, + 'command': true, + 'embed': true, + 'hr': true, + 'img': true, + 'input': true, + 'keygen': true, + 'link': true, + 'meta': true, + 'param': true, + 'source': true, + 'track': true, + 'wbr': true +} + +/** + * CSS properties which accept numbers but are not in units of "px". + */ +export const isUnitlessNumber = { + animationIterationCount: true, + borderImageOutset: true, + borderImageSlice: true, + borderImageWidth: true, + boxFlex: true, + boxFlexGroup: true, + boxOrdinalGroup: true, + columnCount: true, + flex: true, + flexGrow: true, + flexPositive: true, + flexShrink: true, + flexNegative: true, + flexOrder: true, + gridRow: true, + gridColumn: true, + fontWeight: true, + lineClamp: true, + lineHeight: true, + opacity: true, + order: true, + orphans: true, + tabSize: true, + widows: true, + zIndex: true, + zoom: true, + + // SVG-related properties + fillOpacity: true, + floodOpacity: true, + stopOpacity: true, + strokeDasharray: true, + strokeDashoffset: true, + strokeMiterlimit: true, + strokeOpacity: true, + strokeWidth: true +} + +export function encodeEntities (text): string { + if (typeof text === 'boolean' || typeof text === 'number') { + return '' + text + } + return escapeText(text) +} + +// TODO: use extend from nerv.js module +export function assign (obj, props) { + for (const i in props) { + obj[i] = props[i] + } + return obj +}