├── .babelrc ├── .circleci └── config.yml ├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── jest.config.js ├── package.json ├── server.js ├── src ├── __test__ │ └── index.spec.ts ├── index.ts └── server.ts └── tsconfig.json /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "airbnb" 4 | ] 5 | } -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Javascript Node CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-javascript/ for more details 4 | # 5 | version: 2 6 | jobs: 7 | build: 8 | docker: 9 | # specify the version you desire here 10 | - image: circleci/node:10.16 11 | 12 | working_directory: ~/hypernova-vue 13 | 14 | steps: 15 | - checkout 16 | 17 | # Download and cache dependencies 18 | - restore_cache: 19 | keys: 20 | - v1-dependencies-{{ checksum "package.json" }} 21 | # fallback to using the latest cache if no exact match is found 22 | - v1-dependencies- 23 | 24 | - run: yarn install 25 | 26 | - save_cache: 27 | paths: 28 | - node_modules 29 | key: v1-dependencies-{{ checksum "package.json" }} 30 | 31 | # run linter 32 | - run: yarn lint 33 | 34 | # run tests 35 | - run: yarn test 36 | 37 | publish: 38 | docker: 39 | # specify the version you desire here 40 | - image: circleci/node:10.16 41 | 42 | working_directory: ~/hypernova-vue 43 | 44 | steps: 45 | - checkout 46 | 47 | # Download and cache dependencies 48 | - restore_cache: 49 | keys: 50 | - v1-dependencies-{{ checksum "package.json" }} 51 | # fallback to using the latest cache if no exact match is found 52 | - v1-dependencies- 53 | 54 | - run: yarn install 55 | 56 | - run: yarn build 57 | 58 | - save_cache: 59 | paths: 60 | - node_modules 61 | key: v1-dependencies-{{ checksum "package.json" }} 62 | 63 | # run linter 64 | - run: yarn semantic-release 65 | 66 | workflows: 67 | version: 2 68 | main: 69 | jobs: 70 | - build 71 | - publish: 72 | requires: 73 | - build 74 | filters: 75 | branches: 76 | only: master -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | indent_size = 2 3 | indent_style = space -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | lib 2 | node_modules -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "node": true, 5 | "jest": true 6 | }, 7 | "extends": [ 8 | "plugin:@typescript-eslint/recommended", 9 | "airbnb-base" 10 | ], 11 | "rules": { 12 | "import/no-unresolved": 0, 13 | "import/extensions": 0 14 | } 15 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Directory for instrumented libs generated by jscoverage/JSCover 7 | lib-cov 8 | 9 | # Coverage directory used by tools like istanbul 10 | coverage 11 | 12 | # node-waf configuration 13 | .lock-wscript 14 | 15 | # Compiled binary addons (http://nodejs.org/api/addons.html) 16 | build/Release 17 | 18 | # Dependency directory 19 | node_modules 20 | 21 | # Optional npm cache directory 22 | .npm 23 | 24 | # Optional REPL history 25 | .node_repl_history 26 | 27 | TODO 28 | coverage 29 | lib 30 | 31 | # Only apps should have lockfiles 32 | npm-shrinkwrap.json 33 | yarn.lock 34 | package-lock.json -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | !lib 2 | .circleci -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 Felipe Guizar Diaz 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # hypernova-vue 2 | 3 | [Vue.js](https://github.com/vuejs/vue) bindings for [Hypernova](https://github.com/airbnb/hypernova). 4 | 5 | On the server, wraps the component in a function to render it to a HTML string given its props. 6 | 7 | On the client, calling this function with your component scans the DOM for any server-side rendered instances of it. It then resumes those components using the server-specified props. 8 | 9 | ## Install 10 | 11 | ```sh 12 | npm install hypernova-vue 13 | ``` 14 | 15 | ## Usage 16 | 17 | Here's how to use it in your module: 18 | 19 | ```js 20 | import { renderVue, Vue } from 'hypernova-vue' 21 | import HeaderComponent from './components/HeaderComponent.vue' 22 | 23 | const Header = Vue.extend(HeaderComponent) 24 | 25 | export default renderVue('Header', Header) 26 | ``` 27 | 28 | ## Usage with Vuex 29 | 30 | 31 | ```js 32 | import { renderVuex, Vue } from 'hypernova-vue' 33 | import createStore from './store' 34 | import HeaderComponent from './components/HeaderComponent.vue' 35 | 36 | export default renderVuex('Header', HeaderComponent, createStore) 37 | ``` 38 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ['/src'], 3 | transform: { 4 | '^.+\\.tsx?$': 'ts-jest', 5 | }, 6 | testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$', 7 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], 8 | }; 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hypernova-vue", 3 | "version": "3.0.1", 4 | "description": "Vue bindings for Hypernova", 5 | "main": "lib/index.js", 6 | "types": "lib/index.d.ts", 7 | "author": "Felipe Guizar Diaz ", 8 | "scripts": { 9 | "lint": "eslint src/**/*.ts", 10 | "build": "tsc", 11 | "test": "jest", 12 | "semantic-release": "semantic-release" 13 | }, 14 | "keywords": [ 15 | "vuew", 16 | "hypernova", 17 | "server", 18 | "render" 19 | ], 20 | "license": "MIT", 21 | "repository": { 22 | "type": "git", 23 | "url": "https://github.com/ara-framework/hypernova-vue.git" 24 | }, 25 | "devDependencies": { 26 | "@babel/runtime": "^7.6.0", 27 | "@types/jest": "^24.0.18", 28 | "@types/node": "^12.7.4", 29 | "@typescript-eslint/eslint-plugin": "^2.1.0", 30 | "@typescript-eslint/parser": "^2.1.0", 31 | "eslint": "^6.3.0", 32 | "eslint-config-airbnb-base": "^14.0.0", 33 | "eslint-plugin-import": "^2.20.2", 34 | "jest": "^24.9.0", 35 | "ts-jest": "^24.0.2", 36 | "typescript": "^3.6.0", 37 | "semantic-release": "^15.13.24" 38 | }, 39 | "dependencies": { 40 | "hypernova": "^2.5.0", 41 | "nova-helpers": "^1.0.1-alpha.0", 42 | "vue": "^2.6.6", 43 | "vue-server-renderer": "^2.6.6" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./lib/server.js'); 2 | -------------------------------------------------------------------------------- /src/__test__/index.spec.ts: -------------------------------------------------------------------------------- 1 | import Vue, { VNode } from 'vue'; 2 | import { 3 | loadById, 4 | mountComponent, 5 | renderInPlaceholder, 6 | renderVue, 7 | } from '..'; 8 | 9 | describe('loadById', () => { 10 | beforeEach(() => { 11 | document.body.innerHTML = ''; 12 | }); 13 | 14 | test('should load payload by id', () => { 15 | document.body.innerHTML = ` 16 |
17 | 18 | `; 19 | 20 | const payload = loadById('Example', 'd0a0b082-dad0-4bf2-ae4f-08eff16575b4'); 21 | 22 | const { node, data } = payload; 23 | 24 | expect(node.getAttribute('data-hypernova-key')).toEqual('Example'); 25 | expect(node.getAttribute('data-hypernova-id')).toEqual('d0a0b082-dad0-4bf2-ae4f-08eff16575b4'); 26 | expect(data).toEqual({ 27 | title: 'Ara Framework', 28 | }); 29 | }); 30 | 31 | test('should not load payload by id', () => { 32 | const payload = loadById('Example', 'd0a0b082-dad0-4bf2-ae4f-08eff16575b4'); 33 | 34 | expect(payload).toBeNull(); 35 | }); 36 | }); 37 | 38 | describe('mountComponent', () => { 39 | beforeEach(() => { 40 | document.body.innerHTML = ''; 41 | }); 42 | 43 | test('should mount component correctly', () => { 44 | document.body.innerHTML = '
'; 45 | 46 | const app = Vue.extend({ 47 | props: ['title'], 48 | render(h): VNode { 49 | return h('h1', {}, this.title); 50 | }, 51 | }); 52 | 53 | const node = document.getElementById('app'); 54 | 55 | mountComponent(app, node, { title: 'Ara Framework' }); 56 | 57 | expect(node.innerHTML).toEqual('

Ara Framework

'); 58 | }); 59 | 60 | test('should mount component correctly ignoring html comments', () => { 61 | document.body.innerHTML = '
'; 62 | 63 | const app = Vue.extend({ 64 | props: ['title'], 65 | render(h): VNode { 66 | return h('h1', {}, this.title); 67 | }, 68 | }); 69 | 70 | const node = document.getElementById('app'); 71 | 72 | mountComponent(app, node, { title: 'Ara Framework' }); 73 | 74 | expect(node.innerHTML).toEqual('

Ara Framework

'); 75 | }); 76 | }); 77 | 78 | describe('renderInPlaceholder', () => { 79 | beforeEach(() => { 80 | document.body.innerHTML = ''; 81 | }); 82 | 83 | test('should render component in placeholder correctly', () => { 84 | document.body.innerHTML = ` 85 |
86 | 87 | `; 88 | 89 | const app = Vue.extend({ 90 | props: ['title'], 91 | render(h): VNode { 92 | return h('h1', {}, this.title); 93 | }, 94 | }); 95 | 96 | renderInPlaceholder('Example', app, 'd0a0b082-dad0-4bf2-ae4f-08eff16575b4'); 97 | 98 | const expectedHTML = ` 99 |

Ara Framework

100 | 101 | `; 102 | expect(document.body.innerHTML).toEqual(expectedHTML); 103 | }); 104 | }); 105 | 106 | describe('renderVue', () => { 107 | beforeEach(() => { 108 | document.body.innerHTML = ''; 109 | }); 110 | 111 | test('should render all the components in the body', () => { 112 | document.body.innerHTML = ` 113 |
114 | 115 |
116 | 117 | `; 118 | 119 | const app = Vue.extend({ 120 | props: ['title'], 121 | render(h): VNode { 122 | return h('h1', {}, this.title); 123 | }, 124 | }); 125 | 126 | renderVue('Example', app); 127 | 128 | const expectedHTML = ` 129 |

Ara Framework

130 | 131 |

Ara Framework 2

132 | 133 | `; 134 | 135 | expect(document.body.innerHTML).toEqual(expectedHTML); 136 | }); 137 | }); 138 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import Vue, { VueConstructor } from 'vue'; 2 | import hypernova, { load } from 'hypernova'; 3 | import { findNode, getData } from 'nova-helpers'; 4 | import { CombinedVueInstance } from 'vue/types/vue'; 5 | 6 | type HypernovaPayload = { 7 | node: HTMLElement; 8 | data: any; 9 | } 10 | 11 | type VueInstance = CombinedVueInstance 12 | 13 | type VueWithStoreInstance = 14 | CombinedVueInstance & { $store: any }; 15 | 16 | export { default as Vue } from 'vue'; 17 | 18 | export { load } from 'hypernova'; 19 | 20 | export const mountComponent = ( 21 | Component: VueConstructor, 22 | node: HTMLElement, 23 | data: any, 24 | ): VueInstance => { 25 | const vm = new Component({ 26 | propsData: data, 27 | }); 28 | 29 | if (!node.childElementCount) { 30 | node.appendChild(document.createElement('div')); 31 | } 32 | 33 | vm.$mount(node.children[0]); 34 | 35 | return vm; 36 | }; 37 | 38 | export const renderInPlaceholder = ( 39 | name: string, 40 | Component: VueConstructor, 41 | id: string, 42 | ): VueInstance => { 43 | const node: HTMLElement = findNode(name, id); 44 | const data: any = getData(name, id); 45 | 46 | if (node && data) { 47 | return mountComponent(Component, node, data); 48 | } 49 | 50 | return null; 51 | }; 52 | 53 | export const loadById = (name: string, id: string): HypernovaPayload => { 54 | const node = findNode(name, id); 55 | const data = getData(name, id); 56 | 57 | if (node && data) { 58 | return { 59 | node, 60 | data, 61 | }; 62 | } 63 | 64 | return null; 65 | }; 66 | 67 | export const renderVue = (name: string, Component: VueConstructor): void => hypernova({ 68 | server() { 69 | throw new Error('Use hypernova-vue/server instead'); 70 | }, 71 | 72 | client() { 73 | const payloads = load(name); 74 | if (payloads) { 75 | payloads.forEach((payload: HypernovaPayload) => { 76 | const { node, data: propsData } = payload; 77 | 78 | mountComponent(Component, node, propsData); 79 | }); 80 | } 81 | 82 | return Component; 83 | }, 84 | }); 85 | 86 | export const renderVuex = ( 87 | name: string, 88 | ComponentDefinition: any, 89 | createStore: Function, 90 | ): void => hypernova({ 91 | server() { 92 | throw new Error('Use hypernova-vue/server instead'); 93 | }, 94 | 95 | client() { 96 | const payloads = load(name); 97 | if (payloads) { 98 | payloads.forEach((payload: HypernovaPayload) => { 99 | const { node, data } = payload; 100 | const { propsData, state } = data; 101 | 102 | const store = createStore(); 103 | store.replaceState(state); 104 | 105 | const Component: VueConstructor = Vue.extend({ 106 | ...ComponentDefinition, 107 | store, 108 | }); 109 | 110 | const vm = mountComponent(Component, node, propsData) as VueWithStoreInstance; 111 | }); 112 | } 113 | 114 | return ComponentDefinition; 115 | }, 116 | }); 117 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import Vue, { VueConstructor } from 'vue'; 2 | import { createRenderer } from 'vue-server-renderer'; 3 | import hypernova, { serialize } from 'hypernova'; 4 | import { CombinedVueInstance } from 'vue/types/vue'; 5 | 6 | 7 | type VueWithStoreInstance = 8 | CombinedVueInstance & { $store: any }; 9 | 10 | export { default as Vue } from 'vue'; 11 | 12 | export const renderVue = (name: string, Component: VueConstructor): void => hypernova({ 13 | server() { 14 | return async (propsData: object): Promise => { 15 | const vm = new Component({ 16 | propsData, 17 | }); 18 | 19 | const renderer = createRenderer(); 20 | 21 | const contents = await renderer.renderToString(vm); 22 | 23 | return serialize(name, contents, propsData); 24 | }; 25 | }, 26 | 27 | client() { 28 | throw new Error('Use hypernova-vue instead'); 29 | }, 30 | }); 31 | 32 | 33 | export const renderVuex = ( 34 | name: string, 35 | ComponentDefinition: any, 36 | createStore: Function, 37 | ): void => hypernova({ 38 | server() { 39 | return async (propsData: object): Promise => { 40 | const store = createStore(); 41 | 42 | const Component = Vue.extend({ 43 | ...ComponentDefinition, 44 | store, 45 | }); 46 | 47 | const vm = (new Component({ 48 | propsData, 49 | })) as VueWithStoreInstance; 50 | 51 | const renderer = createRenderer(); 52 | 53 | const contents = await renderer.renderToString(vm); 54 | 55 | return serialize(name, contents, { propsData, state: vm.$store.state }); 56 | }; 57 | }, 58 | 59 | client() { 60 | throw new Error('Use hypernova-vue instead'); 61 | }, 62 | }); 63 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "esnext", 5 | "declaration": true, 6 | "outDir": "./lib", 7 | "esModuleInterop": true 8 | }, 9 | "include": [ 10 | "src/**/*" 11 | ], 12 | "exclude": ["node_modules", "**/*.spec.ts"] 13 | } --------------------------------------------------------------------------------