From 82b5ca6b50f0d0247e78befa9520829a8480f4c8 Mon Sep 17 00:00:00 2001 From: Maxime Thirouin Date: Thu, 3 Sep 2015 23:20:17 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=92=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .babelrc | 3 + .editorconfig | 14 ++ .eslintrc | 78 ++++++++ .gitignore | 5 + .travis.yml | 3 + CHANGELOG.md | 3 + LICENSE | 21 +++ README.md | 38 ++++ demo/build.js | 168 ++++++++++++++++++ demo/client.js | 22 +++ demo/content/blog.md | 6 + demo/content/blog/react.md | 7 + demo/content/blog/test.md | 11 ++ demo/content/contact.md | 5 + demo/package.json | 17 ++ demo/web_modules/Collection/index.js | 78 ++++++++ demo/web_modules/Home/index.js | 17 ++ demo/web_modules/Layout/index.js | 23 +++ demo/web_modules/NotFound/index.js | 13 ++ demo/web_modules/Page/index.js | 57 ++++++ demo/web_modules/Post/index.js | 21 +++ demo/web_modules/app/pageComponents.js | 3 + demo/web_modules/app/routes.js | 16 ++ .../THIS_FOLDER_IS_A_HACK_FOR_DEMO_ONLY | 0 package.json | 56 ++++++ src/PageContainer/index.js | 106 +++++++++++ src/build/dev-server.js | 90 ++++++++++ src/build/index.js | 84 +++++++++ src/build/webpack.js | 30 ++++ src/client.js | 60 +++++++ src/configurator.js | 56 ++++++ src/createStore.js | 50 ++++++ src/dev-index.html | 12 ++ src/ducks/README.md | 1 + src/ducks/collection.js | 19 ++ src/ducks/index.js | 3 + src/ducks/pageComponents.js | 11 ++ src/ducks/pages.js | 82 +++++++++ src/enhance-collection/index.js | 111 ++++++++++++ src/fetchJSON/index.js | 33 ++++ src/json-collection-loader/cache.js | 9 + src/json-collection-loader/index.js | 34 ++++ src/json-collection-loader/package.json | 13 ++ src/json-collection-loader/plugin.js | 28 +++ src/markdown-as-json-loader/index.js | 51 ++++++ src/markdown-as-json-loader/package.json | 13 ++ src/to-static-html/Html.js | 47 +++++ src/to-static-html/index.js | 88 +++++++++ src/to-static-html/url-as-html.js | 67 +++++++ 49 files changed, 1783 insertions(+) create mode 100644 .babelrc create mode 100644 .editorconfig create mode 100644 .eslintrc create mode 100644 .gitignore create mode 100644 .travis.yml create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 demo/build.js create mode 100644 demo/client.js create mode 100644 demo/content/blog.md create mode 100644 demo/content/blog/react.md create mode 100644 demo/content/blog/test.md create mode 100644 demo/content/contact.md create mode 100644 demo/package.json create mode 100644 demo/web_modules/Collection/index.js create mode 100644 demo/web_modules/Home/index.js create mode 100644 demo/web_modules/Layout/index.js create mode 100644 demo/web_modules/NotFound/index.js create mode 100644 demo/web_modules/Page/index.js create mode 100644 demo/web_modules/Post/index.js create mode 100644 demo/web_modules/app/pageComponents.js create mode 100644 demo/web_modules/app/routes.js create mode 100644 demo/web_modules/statinamic/THIS_FOLDER_IS_A_HACK_FOR_DEMO_ONLY create mode 100644 package.json create mode 100644 src/PageContainer/index.js create mode 100644 src/build/dev-server.js create mode 100644 src/build/index.js create mode 100644 src/build/webpack.js create mode 100644 src/client.js create mode 100644 src/configurator.js create mode 100644 src/createStore.js create mode 100644 src/dev-index.html create mode 100644 src/ducks/README.md create mode 100644 src/ducks/collection.js create mode 100644 src/ducks/index.js create mode 100644 src/ducks/pageComponents.js create mode 100644 src/ducks/pages.js create mode 100644 src/enhance-collection/index.js create mode 100644 src/fetchJSON/index.js create mode 100644 src/json-collection-loader/cache.js create mode 100644 src/json-collection-loader/index.js create mode 100644 src/json-collection-loader/package.json create mode 100644 src/json-collection-loader/plugin.js create mode 100644 src/markdown-as-json-loader/index.js create mode 100644 src/markdown-as-json-loader/package.json create mode 100644 src/to-static-html/Html.js create mode 100644 src/to-static-html/index.js create mode 100644 src/to-static-html/url-as-html.js diff --git a/.babelrc b/.babelrc new file mode 100644 index 000000000..b0b9a96ef --- /dev/null +++ b/.babelrc @@ -0,0 +1,3 @@ +{ + "stage": 0 +} diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..c1b55337d --- /dev/null +++ b/.editorconfig @@ -0,0 +1,14 @@ +# editorconfig.org +root = true + +[*] +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true +indent_style = space +indent_size = 2 + +[*.md] +# Allow
from Markdown +trim_trailing_whitespace = false diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 000000000..cc22cddb9 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,78 @@ +--- +root: true +extends: eslint:recommended + +# babel-eslint support more syntax stuff than eslint for now +parser: babel-eslint + +ecmaFeatures: + modules: true + jsx: true + +env: + es6: true + browser: true + node: true + +globals: + __DEV__: true + __DEVTOOLS__: true + __DEVSERVER__: true + __PROD__: true + __SERVER_PROTOCOL__: true + __SERVER_HOSTNAME__: true + __SERVER_PORT__: true + __SERVER_HOST__: true + __SERVER_URL__: true + +plugins: + - react + +# 0: off, 1: warning, 2: error +rules: + no-console: 0 + + indent: [2, 2] # 2 spaces indentation + max-len: [2, 80, 4] + quotes: [2, "double"] + semi: [2, "never"] + no-multiple-empty-lines: [2, {"max": 1}] + + brace-style: [2, "stroustrup"] + comma-dangle: [2, "always-multiline"] + comma-style: [2, "last"] + dot-location: [2, "property"] + + one-var: [2, "never"] + no-var: [2] + prefer-const: [2] + no-bitwise: [2] + + object-curly-spacing: [2, "always"] + array-bracket-spacing: [2, "always"] + #computed-property-spacing: [2, "always"] + + space-unary-ops: [2, {"words": true, "nonwords": false}] + space-after-keywords: [2, "always"] + space-before-blocks: [2, "always"] + space-before-function-paren: [2, "never"] + space-in-parens: [2, "never"] + spaced-comment: [2, "always"] + + # eslint-plugin-react rules + react/jsx-boolean-value: 2 + react/jsx-no-undef: 2 + react/jsx-quotes: 2 + #react/jsx-sort-prop-types: 2 + #react/jsx-sort-props: 2 + react/jsx-uses-react: 2 + react/jsx-uses-vars: 2 + react/no-did-mount-set-state: 2 + react/no-did-update-set-state: 2 + react/no-multi-comp: 2 + react/no-unknown-property: 2 + react/prop-types: 2 + react/react-in-jsx-scope: 2 + react/self-closing-comp: 2 + react/sort-comp: 2 + react/wrap-multilines: 2 diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..08c1973c2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +# build +lib + +# demo build +dist diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 000000000..68b5c45d8 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,3 @@ +language: nodejs +nodejs: + - iojs diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..8efb76fba --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +# 0.0.0 + +No release has been made yet. Everything is highly subject to changes. diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..6df92a4ae --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2015 Maxime Thirouin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 000000000..764088f49 --- /dev/null +++ b/README.md @@ -0,0 +1,38 @@ +# statinamic + +> A static website generator that create dynamic website using React components. + +![Travis Build Badge](https://img.shields.io/travis/MoOx/statinamic/master.svg) + +## Install + +```console +$ npm install MoOx/statinamic +``` + +## Usage + +See [demo](demo) for now. + +```console +$ npm install + +# static build +$ npm test + +# live demo +$ npm run demo +``` + +--- + +## Contributing + +* ⇄ Pull requests and ★ stars are always welcome. +* For bugs and feature requests, please create an issue. +* Pull requests that modifies code must be accompanied with automated tests. +* Run `$ npm test` before making a pull request. + +## [CHANGELOG](CHANGELOG.md) + +## [LICENSE](LICENSE) diff --git a/demo/build.js b/demo/build.js new file mode 100644 index 000000000..2d384c94e --- /dev/null +++ b/demo/build.js @@ -0,0 +1,168 @@ +import path from "path" +import webpack from "webpack" +import ExtractTextPlugin from "extract-text-webpack-plugin" + +import markdownIt from "markdown-it" +import markdownItTocAndAnchor from "markdown-it-toc-and-anchor" +import hljs from "highlight.js" + +import pkg from "./package.json" + +import routes from "app/routes" +// import * as reducers from "app/ducks" +import * as pageComponents from "app/pageComponents" +// instead of using the collection.json that has been made in during build +// we directly use the module cache responsible of the build, since it's still +// in memory. This avoid us a fs read + handling some potential async issues +// (since the collection.json is made by a plugin _after_ the build) +import collection from "statinamic/lib/json-collection-loader/cache" + +import build from "statinamic/lib/build" +import configurator from "statinamic/lib/configurator" +import jsonCollectionPlugin from + "statinamic/lib/json-collection-loader/plugin" + +const config = configurator(pkg) + +const root = path.join(__dirname) +const source = path.join(root, "content") +const dest = path.join(root, "dist") + +build({ + config, + source, + dest, + + exports: { + routes, + initialState: { + pageComponents, + collection, + }, + }, + + webpack: { + entry: { + index: [ + path.join(__dirname, "client"), + ], + }, + + output: { + path: dest, + filename: "[name].js", + publicPath: "/", + }, + + resolve: { + extensions: [ + // node default extensions + ".js", + ".json", + // for all other extensions specified directly + "", + ], + }, + + module: { + // ! \\ note that loaders are executed from bottom to top ! + loaders: [ + // + // statinamic requirement + // + { + test: /\.md$/, + loaders: [ + `file?name=[path][name]/index.json&context=${ source }`, + "statinamic/lib/json-collection-loader", + "statinamic/lib/markdown-as-json-loader", + ], + }, + { + test: /\.json$/, + loaders: [ + "json", + ], + }, + + // your loaders + { + test: /\.js$/, + loaders: [ + ...config.__DEV__ && [ "react-hot" ], + "babel", + ...config.__DEV__ && [ "eslint" ], + ], + exclude: /node_modules/, + }, + ], + }, + + plugins: [ + // + // statinamic requirement + // + jsonCollectionPlugin({ + filename: "collection.json", + }), + + // your plugins + new webpack.DefinePlugin( + // transform string as "string" so hardcoded replacements are + // syntaxically correct + Object.keys(config).reduce((obj, constName) => { + const value = config[constName] + return { + ...obj, + [constName]: ( + typeof value === "string" ? JSON.stringify(value) : value + ), + } + }, {}) + ), + new ExtractTextPlugin("[name].css", { disable: !config.__PROD__ }), + ...config.__PROD__ && [ + new webpack.optimize.DedupePlugin(), + new webpack.optimize.UglifyJsPlugin({ + compress: { + warnings: false, + }, + }), + ], + ], + + node: { + // https://github.com/webpack/webpack/issues/451 + // run tape test with webpack + fs: "empty", + }, + + markdownIt: ( + markdownIt({ + html: true, + linkify: true, + typographer: true, + highlight: (code, lang) => { + code = code.trim() + // language is recognized by highlight.js + if (lang && hljs.getLanguage(lang)) { + return hljs.highlight(lang, code).value + } + // ...or fallback to auto + return hljs.highlightAuto(code).value + }, + }) + .use(markdownItTocAndAnchor, { + tocFirstLevel: 2, + }) + ), + + jsonCollection: { + urlify: (url) => url + // .replace(/^content\//, "") + .replace(/^demo\/content\//, "") + .replace(/\.md$/, "") + , + }, + }, +}) diff --git a/demo/client.js b/demo/client.js new file mode 100644 index 000000000..fa1435cdc --- /dev/null +++ b/demo/client.js @@ -0,0 +1,22 @@ +import statinamic from "statinamic/lib/client" + +import routes from "app/routes" +// import * as reducers from "app/ducks" +import * as pageComponents from "app/pageComponents" + +// dev index +if (__DEV__) { + require("!!file?name=index.html!statinamic/lib/dev-index.html") +} + +// all md files as JSON + generate collections +require.context("./content", true, /\.md$/) + +statinamic({ + routes, + // reducers, + initialState: { + ...window.__INITIAL_STATE__, + pageComponents, + }, +}) diff --git a/demo/content/blog.md b/demo/content/blog.md new file mode 100644 index 000000000..24ee1e0e4 --- /dev/null +++ b/demo/content/blog.md @@ -0,0 +1,6 @@ +--- +title: Blog +layout: Collection +--- + +Here is a list of posts. Below are other pages. diff --git a/demo/content/blog/react.md b/demo/content/blog/react.md new file mode 100644 index 000000000..30d89b8b7 --- /dev/null +++ b/demo/content/blog/react.md @@ -0,0 +1,7 @@ +--- +title: React blog post +layout: Post +date: 2015-08-20 +--- + +That's cool yo. diff --git a/demo/content/blog/test.md b/demo/content/blog/test.md new file mode 100644 index 000000000..072e13c6b --- /dev/null +++ b/demo/content/blog/test.md @@ -0,0 +1,11 @@ +--- +title: TeStInG +layout: Post +date: 2015-01-02 +--- + +This is a **post**. + +## Hell yeah + +You feel the nice platform, do you? diff --git a/demo/content/contact.md b/demo/content/contact.md new file mode 100644 index 000000000..385e06afe --- /dev/null +++ b/demo/content/contact.md @@ -0,0 +1,5 @@ +--- +title: Contact +--- + +Mail yo. diff --git a/demo/package.json b/demo/package.json new file mode 100644 index 000000000..b53450b9b --- /dev/null +++ b/demo/package.json @@ -0,0 +1,17 @@ +{ + "private": true, + "name": "statinamic-demo", + "url": "http://moox.io/statinamic", + "version": "0.0.0", + "author": "Maxime Thirouin", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/MoOx/statinamic.git" + }, + "statinamic": { + "hostname": "moox.io" + }, + "twitter": "MoOx", + "github": "MoOx" +} diff --git a/demo/web_modules/Collection/index.js b/demo/web_modules/Collection/index.js new file mode 100644 index 000000000..fd44d842f --- /dev/null +++ b/demo/web_modules/Collection/index.js @@ -0,0 +1,78 @@ +import React, { Component } from "react" +import { PropTypes } from "react" + +import Page from "Page" + +import { connect } from "react-redux" +import enhanceCollection from "statinamic/lib/enhance-collection" + +export default +@connect( + ({ collection }) => { + return { collection } + } +) +class Collection extends Component { + + static propTypes = { + head: PropTypes.object.isRequired, + body: PropTypes.string.isRequired, + collection: PropTypes.array, + } + + render() { + const { + collection, + } = this.props + return ( +
+ + { + collection && collection.length && + +
+ { "Posts (by date)" } +
    + { + enhanceCollection(collection, { + filter: { layout: "Post" }, + sort: "date", + reverse: true, + // limit: 1, + }) + .map((item) => { + return ( +
  • + + { item.title } + +
  • + ) + }) + } +
+ + { "Other pages" } +
    + { + enhanceCollection(collection, { + filter: ({ layout }) => layout !== "Post", + sort: "title", + }) + .map((item) => { + return ( +
  • + + { item.title } + +
  • + ) + }) + } +
+
+ } +
+ ) + } +} diff --git a/demo/web_modules/Home/index.js b/demo/web_modules/Home/index.js new file mode 100644 index 000000000..c75b1b1c4 --- /dev/null +++ b/demo/web_modules/Home/index.js @@ -0,0 +1,17 @@ +import React, { Component } from "react" +// import { PropTypes } from "react" + +export default class Home extends Component { + + // static propTypes = { + // title: PropTypes.bool, + // } + + render() { + return ( +
+ Home ! +
+ ) + } +} diff --git a/demo/web_modules/Layout/index.js b/demo/web_modules/Layout/index.js new file mode 100644 index 000000000..b3fc8cf03 --- /dev/null +++ b/demo/web_modules/Layout/index.js @@ -0,0 +1,23 @@ +import React, { Component } from "react" +import { PropTypes } from "react" +import { Link } from "react-router" + +export default class Layout extends Component { + + static propTypes = { + children: PropTypes.oneOfType([ PropTypes.array, PropTypes.object ]), + } + + render() { + return ( +
+ + {this.props.children} +
+ ) + } +} diff --git a/demo/web_modules/NotFound/index.js b/demo/web_modules/NotFound/index.js new file mode 100644 index 000000000..5dc0625fd --- /dev/null +++ b/demo/web_modules/NotFound/index.js @@ -0,0 +1,13 @@ +import React, { Component } from "react" +// import { PropTypes } from "react" + +export default class NotFound extends Component { + + render() { + return ( +
+ 404 Not Found +
+ ) + } +} diff --git a/demo/web_modules/Page/index.js b/demo/web_modules/Page/index.js new file mode 100644 index 000000000..187bf01fd --- /dev/null +++ b/demo/web_modules/Page/index.js @@ -0,0 +1,57 @@ +import React, { Component } from "react" +import { PropTypes } from "react" +import Helmet from "react-helmet" + +import pkg from "../../package.json" + +// function pageDescription(text) { +// return text +// } + +export default class Page extends Component { + + static propTypes = { + children: PropTypes.oneOfType([ PropTypes.array, PropTypes.object ]), + head: PropTypes.object.isRequired, + body: PropTypes.string.isRequired, + } + + render() { + const { + head, + body, + } = this.props + + const meta = [ + { name: "twitter:title", content: head.title }, + { name: "twitter:creator", content: `@${ pkg.twitter }` }, + // { name: "twitter:site", content: `@${ pkg.twitter }` }, + // { name: "twitter:description", content: pageDescription(body) }, + // { name: "twitter:image", content: header.image }, + { property: "og:site_name", content: pkg.name }, + { property: "og:title", content: head.title }, + { property: "og:type", content: "article" }, + // { property: "og:url", content: "http://www.example.com/" }, + // { property: "og:description", content: pageDescription(body) }, + // { property: "og:image", content: header.image }, + ] + + return ( +
+ + +

{ head.title }

+ { + body && +
+ } + { this.props.children } +
+ ) + } +} diff --git a/demo/web_modules/Post/index.js b/demo/web_modules/Post/index.js new file mode 100644 index 000000000..189fc3481 --- /dev/null +++ b/demo/web_modules/Post/index.js @@ -0,0 +1,21 @@ +import React, { Component } from "react" +import { PropTypes } from "react" + +import Page from "Page" + +export default class Post extends Component { + + static propTypes = { + head: PropTypes.object.isRequired, + body: PropTypes.string.isRequired, + } + + render() { + return ( +
+ { "Post !"} + +
+ ) + } +} diff --git a/demo/web_modules/app/pageComponents.js b/demo/web_modules/app/pageComponents.js new file mode 100644 index 000000000..71f0c2609 --- /dev/null +++ b/demo/web_modules/app/pageComponents.js @@ -0,0 +1,3 @@ +export Page from "Page" +export Post from "Post" +export Collection from "Collection" diff --git a/demo/web_modules/app/routes.js b/demo/web_modules/app/routes.js new file mode 100644 index 000000000..260053674 --- /dev/null +++ b/demo/web_modules/app/routes.js @@ -0,0 +1,16 @@ +import React from "react" +import { Route } from "react-router" +// import PageContainer from "statinamic/lib/PageContainer" +import PageContainer from "../../../src/PageContainer" + +// components +import Layout from "Layout" +import Home from "Home" + +// routes +export default ( + + + + +) diff --git a/demo/web_modules/statinamic/THIS_FOLDER_IS_A_HACK_FOR_DEMO_ONLY b/demo/web_modules/statinamic/THIS_FOLDER_IS_A_HACK_FOR_DEMO_ONLY new file mode 100644 index 000000000..e69de29bb diff --git a/package.json b/package.json new file mode 100644 index 000000000..628c38433 --- /dev/null +++ b/package.json @@ -0,0 +1,56 @@ +{ + "private": true, + "name": "statinamic", + "version": "0.0.0", + "author": "Maxime Thirouin", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/MoOx/statinamic.git" + }, + "dependencies": { + "chalk": "^1.1.0", + "eslint": "^1.1.0", + "eslint-plugin-react": "^3.2.2", + "gray-matter": "^2.0.0", + "loader-utils": "^0.2.11", + "markdown-it": "^4.4.0", + "mkdirp": "^0.5.1", + "nano-logger": "^1.0.0", + "opn": "^3.0.2", + "rimraf": "^2.4.2", + "webpack": "^1.11.0", + "webpack-dev-server": "^1.10.1", + "webpack-error-notification": "^0.1.4", + "webpack-nano-logs": "^1.0.0", + "whatwg-fetch": "^0.9.0" + }, + "devDependencies": { + "babel": "^5.8.21", + "babel-core": "^5.8.22", + "babel-eslint": "^4.0.6", + "babel-loader": "^5.3.2", + "eslint-loader": "^1.0.0", + "extract-text-webpack-plugin": "^0.8.2", + "file-loader": "^0.8.1", + "json-loader": "^0.5.2", + "markdown-it-toc-and-anchor": "^1.0.1", + "react-hot-loader": "^1.1.4", + "highlight.js": "^8.5.0", + "react": "^0.13.3", + "react-helmet": "^1.1.1", + "react": "^0.13.3", + "react-redux": "^0.8.0", + "react-router": "1.0.0-beta3", + "redux": "^1.0.1", + "redux-devtools": "^1.0.2", + "redux-thunk": "^0.1.0" + }, + "scripts": { + "prebabelify": "rimraf lib", + "babelify": "babel --copy-files src --out-dir lib", + "prepublish": "npm run babelify", + "test": "NODE_PATH=demo/web_modules babel-node demo/build --production", + "demo": "NODE_PATH=demo/web_modules babel-node demo/build --dev --dev-server --dev-tools --open" + } +} diff --git a/src/PageContainer/index.js b/src/PageContainer/index.js new file mode 100644 index 000000000..f42a28a1e --- /dev/null +++ b/src/PageContainer/index.js @@ -0,0 +1,106 @@ +import React, { Component } from "react" +import { PropTypes } from "react" + +import { connect } from "react-redux" +import * as pageActions from "../ducks/pages" + +export default +@connect( + ({ pages, pageComponents }) => { + return { pages, pageComponents } + }, + (dispatch) => { + return { + getPage: (page) => dispatch(pageActions.get(page)), + setPageType: (page, type) => dispatch(pageActions.setType(page, type)), + unknownPageType: (page, type) => + dispatch(pageActions.unknownType(page, type)), + } + }, +) +class PageContainer extends Component { + + static propTypes = { + pages: PropTypes.object.isRequired, + pageComponents: PropTypes.object.isRequired, + params: PropTypes.object, + + // components + NotFound: PropTypes.object, + Loading: PropTypes.object, + + // actions + setPageType: PropTypes.func.isRequired, + unknownPageType: PropTypes.func.isRequired, + } + + componentWillMount() { + this.preparePage(this.props) + } + + componentWillReceiveProps(nextProps) { + this.preparePage(nextProps) + } + + preparePage(props) { + const pageKey = props.params.splat.replace(/\/index\.html$/, "") + const page = props.pages[pageKey] + if (!page) { + props.getPage(pageKey) + } + else { + if (page.error) { + return + } + + if (!page.type) { + let pageComponentName = "Page" + if (page && page.head) { + pageComponentName = page.head.layout || page.head.type + } + + const PageComponent = props.pageComponents[pageComponentName] + if (!PageComponent) { + props.unknownPageType(pageKey, pageComponentName) + } + } + } + } + + render() { + // console.log(this.props) + const pageKey = this.props.params.splat + + const page = this.props.pages[pageKey] + if (!page) { + // console.log("FUCK WAT") + return null + } + + const PageComponent = this.props.pageComponents[page.type] + + return ( +
+ { + page && !page.error && page.loading && this.props.Loading + } + + { + page && !!page.error && !this.props.NotFound && +
+

{ page.error }

+

{ page.errorText }

+
+ } + { + page && !!page.error && this.props.NotFound + } + + { + page && !page.error && !page.loading && PageComponent && + + } +
+ ) + } +} diff --git a/src/build/dev-server.js b/src/build/dev-server.js new file mode 100644 index 000000000..2a537ea5b --- /dev/null +++ b/src/build/dev-server.js @@ -0,0 +1,90 @@ +import webpack from "webpack" +import WebpackDevServer from "webpack-dev-server" + +import webpackNanoLogs from "webpack-nano-logs" +import WebpackErrorNotificationPlugin from "webpack-error-notification" + +import opn from "opn" +import logger from "nano-logger" + +const log = logger("webpack-dev-server") + +export default (config, options) => { + options = { + protocol: "http://", + host: "0.0.0.0", + port: 3000, + open: true, + noDevEntriesTest: /^tests/, + ...(options || {}), + } + + const serverUrl = `${ options.protocol }${ options.host }:${ options .port }` + + const devEntries = [ + `webpack-dev-server/client?${ serverUrl }`, + `webpack/hot/only-dev-server`, + ] + + const devConfig = { + ...config, + debug: true, + watch: true, + colors: true, + progress: true, + entry: { + // add devEntries + ...Object.keys(config.entry) + .reduce( + (acc, key) => { + // some entries do not need extra stuff + acc[key] = key.match(options.noDevEntriesTest) !== null + ? config.entry[key] + : [ + ...devEntries, + ...config.entry[key], + ] + return acc + }, + {} + ), + }, + plugins: [ + ...(config.plugins || []), + ...(options.plugins || []), + new webpack.NoErrorsPlugin(), + new webpack.HotModuleReplacementPlugin(), + webpackNanoLogs, + new WebpackErrorNotificationPlugin(), + ], + eslint: { + ...config.eslint, + emitWarning: true, + }, + } + + return new WebpackDevServer( + webpack(devConfig), + { + https: options.protocol === "https://", + contentBase: config.output.path, + hot: true, + stats: { + colors: true, + // hide all chunk dependencies because it's unreadable + chunkModules: false, + // noize + assets: false, + }, + noInfo: true, + + // allow all url to point to index.html + historyApiFallback: true, + }) + .listen(options.port, options.host, () => { + log(`Dev server started on ${ serverUrl }`) + if (options.open) { + opn(`${ serverUrl }`) + } + }) +} diff --git a/src/build/index.js b/src/build/index.js new file mode 100644 index 000000000..af8a5d9e6 --- /dev/null +++ b/src/build/index.js @@ -0,0 +1,84 @@ +import { sync as rm } from "rimraf" +import { sync as mkdir } from "mkdirp" +import color from "chalk" +import nanoLogger from "nano-logger" + +import webpack from "./webpack" +import devServer from "./dev-server" + +export default function(options) { + const { + config, + source, + } = options + const webpackConfig = options.webpack + + const log = nanoLogger("statinamic/lib/build") + + JSON.stringify(config, null, 2).split("\n").forEach(l => log(l)) + + const dest = webpackConfig.output.path + + // cleanup + // rm(dest) + // mkdir(dest) + + if (config.__DEVSERVER__) { + devServer(webpackConfig, { + protocol: config.__SERVER_PROTOCOL__, + host: config.__SERVER_HOSTNAME__, + port: config.__SERVER_PORT__, + open: process.argv.includes("--open"), + }) + } + else { + webpack(webpackConfig, log, (stats) => { + log(color.green("✓ Static assets: build completed")) + + // faking webpack.DefinePlugin for node usage + Object.keys(config).forEach((key) => { + global[key] = config[key] + }) + + const filenameLengthToSkip = source.length + 1 + const extLengthToSkip = ".md".length + + const pageUrls = stats.compilation.fileDependencies.reduce( + (array, filename) => { + if (filename.match(/\.md$/)) { + array.push( + filename + // remove ext + .slice(0, filename.length - extLengthToSkip) + // remove source + .substr(filenameLengthToSkip) + ) + } + return array + }, + [] + ) + + require("../to-static-html")({ + urls: [ "", ...pageUrls ], + source, + dest, + exports: options.exports, + log, + }) + .then( + (files) => { + log( + color.green(`✓ Static html files: ${ files.length } files written`) + ) + }, + (error) => { + log(color.red(`✗ Static html files: failed to create files`)) + setTimeout(() => { + throw error + }, 1) + } + ) + }) + } +} diff --git a/src/build/webpack.js b/src/build/webpack.js new file mode 100644 index 000000000..9de629efd --- /dev/null +++ b/src/build/webpack.js @@ -0,0 +1,30 @@ +import webpack from "webpack" +import color from "chalk" + +export default (webpackConfig, log, cb) => { + webpack(webpackConfig, (err, stats) => { + if (err) { + throw err + } + + if (stats.hasErrors()) { + stats.compilation.errors.forEach( + item => log(...[ + color.red("Error:"), + ...item.message.split("\n"), + ]) + ) + throw new Error("webpack build failed with errors") + } + if (stats.hasWarnings()) { + stats.compilation.warnings.forEach( + item => log(...[ + color.yellow("Warning:"), + ...item.message.split("\n"), + ]) + ) + } + + cb(stats) + }) +} diff --git a/src/client.js b/src/client.js new file mode 100644 index 000000000..4fa711ba3 --- /dev/null +++ b/src/client.js @@ -0,0 +1,60 @@ +// App +import "whatwg-fetch" + +import React from "react" + +import Router from "react-router" + +import { Provider } from "react-redux" +import createStore from "./createStore" + +import fetchJSON from "./fetchJSON" + +export default function statinamic({ + routes, + reducers = {}, + initialState = {}, +}) { + const store = createStore(reducers, initialState) + + // react-router beta4 + // const history = require("history/lib/createBrowserHistory")() + const history = require("react-router/lib/BrowserHistory").history + + let devtools = false + if (__DEVTOOLS__) { + const { + DevTools, + DebugPanel, + LogMonitor, + } = require("redux-devtools/lib/react") + devtools = ( + + + + ) + } + + fetchJSON(`/collection.json`) + .then( + ({ data }) => store.dispatch({ + type: "COLLECTION_SET", + collection: data, + }), + (error) => store.dispatch({ + type: "COLLECTION_ERROR", + error, + }) + ) + + React.render( +
+ + {() => } + + { __DEVTOOLS__ && devtools } +
, + document.getElementById("statinamic") + ) + +} diff --git a/src/configurator.js b/src/configurator.js new file mode 100644 index 000000000..5f016ace5 --- /dev/null +++ b/src/configurator.js @@ -0,0 +1,56 @@ +export default function config(pkg = {}) { + const production = process.argv.includes("--production") + + if (production) { + if (!pkg.statinamic) { + throw new Error( + "Your package.json needs a 'statinamic' configuration section" + ) + } + if (!pkg.statinamic.hostname) { + throw new Error( + "Your package.json/statinamic section require a 'hostname'" + ) + } + } + + // default + const statinamicConfig = { + // hostname: null, + dev: { + hostname: "0.0.0.0", + port: 3000, + }, + ...pkg.statinamic || {}, + } + + const config = { + __DEV__: process.argv.includes("--dev"), + __DEVTOOLS__: process.argv.includes("--dev-tools"), + __DEVSERVER__: process.argv.includes("--dev-server"), + __PROD__: production, + __SERVER_PROTOCOL__: statinamicConfig.protocol || "http://", + ...( + production + ? { + "process.env": { + NODE_ENV: JSON.stringify("production"), + }, + __SERVER_HOSTNAME__: statinamicConfig.hostname, + __SERVER_HOST__: statinamicConfig.hostname, + } + : { + __SERVER_HOSTNAME__: statinamicConfig.dev.hostname, + __SERVER_PORT__: statinamicConfig.dev.port, + __SERVER_HOST__: + statinamicConfig.dev.hostname + + ":" + + statinamicConfig.dev.port, + } + ), + } + + config.__SERVER_URL__ = config.__SERVER_PROTOCOL__ + config.__SERVER_HOST__ + + return config +} diff --git a/src/createStore.js b/src/createStore.js new file mode 100644 index 000000000..4a5b5b8b9 --- /dev/null +++ b/src/createStore.js @@ -0,0 +1,50 @@ +import { createStore, applyMiddleware, combineReducers, compose } from "redux" +import thunk from "redux-thunk" + +import * as reducers from "./ducks" + +export default function(additionalReducers = {}, initialState = {}) { + const reducer = combineReducers({ + ...reducers, + ...additionalReducers, + }) + + function promiseMiddleware() { + return (next) => (action) => { + const { promise, types, ...rest } = action + if (!promise) { + return next(action) + } + else if (!promise.then) { + throw new Error( + "promiseMiddleware expects a promise object that implements then()" + ) + } + + const [ REQUEST, SUCCESS, FAILURE ] = types + next({ ...rest, type: REQUEST }) + return promise.then( + (response) => next({ ...rest, response, type: SUCCESS }), + (error) => next({ ...rest, error, type: FAILURE }) + ) + } + } + + let finalCreateStore + + if (__DEVTOOLS__) { + const { devTools, persistState } = require("redux-devtools") + + finalCreateStore = compose( + applyMiddleware(promiseMiddleware, thunk), + devTools(), + persistState(window.location.href.match(/[?&]debug_session=([^&]+)\b/)), + createStore + ) + } + else { + finalCreateStore = applyMiddleware(promiseMiddleware, thunk)(createStore) + } + + return finalCreateStore(reducer, initialState) +} diff --git a/src/dev-index.html b/src/dev-index.html new file mode 100644 index 000000000..f82f30970 --- /dev/null +++ b/src/dev-index.html @@ -0,0 +1,12 @@ + + + + + + + + +
...
+ + + diff --git a/src/ducks/README.md b/src/ducks/README.md new file mode 100644 index 000000000..d4728bb2b --- /dev/null +++ b/src/ducks/README.md @@ -0,0 +1 @@ +https://github.com/erikras/ducks-modular-redux diff --git a/src/ducks/collection.js b/src/ducks/collection.js new file mode 100644 index 000000000..6d79f13ab --- /dev/null +++ b/src/ducks/collection.js @@ -0,0 +1,19 @@ +export default function reducer(state = [], action) { + + switch (action.type) { + case "COLLECTION_GET": + return [] + + case "COLLECTION_SET": + return action.collection + + case "COLLECTION_FORGET": + return [] + + case "COLLECTION_ERROR": + return [] + + default: + return state + } +} diff --git a/src/ducks/index.js b/src/ducks/index.js new file mode 100644 index 000000000..91a7b0054 --- /dev/null +++ b/src/ducks/index.js @@ -0,0 +1,3 @@ +export collection from "./collection" +export pageComponents from "./pageComponents" +export pages from "./pages" diff --git a/src/ducks/pageComponents.js b/src/ducks/pageComponents.js new file mode 100644 index 000000000..640281988 --- /dev/null +++ b/src/ducks/pageComponents.js @@ -0,0 +1,11 @@ +export default function reducer(state = {}, action) { + switch (action.type) { + case "PAGE_COMPONENTS_SET": + return { + ...action.pageComponents, + } + + default: + return state + } +} diff --git a/src/ducks/pages.js b/src/ducks/pages.js new file mode 100644 index 000000000..78c85af63 --- /dev/null +++ b/src/ducks/pages.js @@ -0,0 +1,82 @@ +import fetchJSON from "../fetchJSON" + +// redux reducer +export default function reducer(state = {}, action) { + + switch (action.type) { + case "PAGE_GET": + return { + ...state, + [action.page]: { + loading: true, + }, + } + + case "PAGE_SET": + const data = action.response.data + return { + ...state, + [action.page]: { + ...data, + type: data.head ? data.head.layout || data.head.type : "Page", + }, + } + + case "PAGE_FORGET": + return { + ...state, + [action.page]: undefined, + } + + case "PAGE_ERROR": + return { + ...state, + [action.page]: action.error.response + ? { + error: action.error.response.status, + errorText: action.error.response.statusText, + } + : { + ...action.error, + }, + } + + default: + return state + } +} + +// redux actions +export function get(page) { + return { + types: [ + "PAGE_GET", + "PAGE_SET", + "PAGE_ERROR", + ], + page, + promise: fetchJSON(`/${ page }/index.json`), + } +} + +export function setType(page, type) { + return { + type: "PAGE_TYPE", + page, + pageType: type, + } +} + +export function unknownType(page, type) { + return { + type: "PAGE_ERROR", + page, + error: { + error: "Unkown page type", + errorText: ( + `"${ type }" component not available in ` + + `"pageComponents" prop` + ), + }, + } +} diff --git a/src/enhance-collection/index.js b/src/enhance-collection/index.js new file mode 100644 index 000000000..e1dbae781 --- /dev/null +++ b/src/enhance-collection/index.js @@ -0,0 +1,111 @@ +export default function enhanceCollection(collection, options) { + options = { + addSiblingReferences: true, + ...options, + } + + if (options.filter) { + collection = filter(collection, [ options.filter ]) + } + + if (options.filters) { + collection = filter(collection, options.filters) + } + + if (options.sort) { + collection = sort(collection, options.sort) + } + + if (options.reverse) { + collection = reverse(collection) + } + + if (options.limit) { + collection = limit(collection, options.limit) + } + + if (options.addSiblingReferences) { + collection = addSiblingReferences(collection) + } + + return collection +} + +export function filter(collection, filters) { + return collection.reduce((acc, item) => { + filters.forEach((filter) => { + switch (typeof filter) { + case "function": + if (filter(item)) { + acc.push(item) + } + break + + case "object": + const keys = Object.keys(filter) + if ( + keys.reduce( + (acc, key) => acc && item[key] === filter[key], + true + ) + ) { + acc.push(item) + } + break + + case "string": + default: + if (item[filter]) { + acc.push(item) + } + break + + } + }) + + return acc + }, []) +} + +export function sort(collection, sort = "date") { + collection = [ ...collection ] + + if (typeof sort === "function") { + collection.sort(sort) + } + else { + collection.sort((a, b) => { + a = a[sort] + b = b[sort] + if (!a && !b) return 0 + if (!a) return -1 + if (!b) return 1 + if (b > a) return -1 + if (a > b) return 1 + return 0 + }) + } + + return collection +} + +export function reverse(collection) { + collection = [ ...collection ] + collection.reverse() + return collection +} + +export function limit(collection, limit) { + return collection.slice(0, limit) +} + +export function addSiblingReferences(collection) { + const last = collection.length - 1 + return collection.map((item, i) => { + return { + ...item, + ...(0 != i) && { previous: collection[i-1] }, + ...(last != i) && { next: collection[i+1] }, + } + }) +} diff --git a/src/fetchJSON/index.js b/src/fetchJSON/index.js new file mode 100644 index 000000000..e69501204 --- /dev/null +++ b/src/fetchJSON/index.js @@ -0,0 +1,33 @@ +export default function(url, data) { + return fetch(url, data) + .then((res) => { + + // get response fields has a raw object + const response = {} + // using spread or Object.keys do not return computed properties + // hasOwnProperty doesn't help + // so good old `for in` that checks simple types + for (const key in res) { + if (typeof res[key] !== "function") { + response[key] = res[key] + } + } + + // for both http ok/not ok, we try to parse as JSON + // or reject if json parsing failed + + // http OK + if (res.status >= 200 && res.status < 300) { + return new Promise((resolve, reject) => res.json().then( + (data) => resolve({ response, data }), + (exception) => reject({ response, exception }), + )) + } + + // http not ok + return new Promise((resolve, reject) => res.json().then( + (data) => reject({ response, data }), + (exception) => reject({ response, exception }), + )) + }) +} diff --git a/src/json-collection-loader/cache.js b/src/json-collection-loader/cache.js new file mode 100644 index 000000000..52cec4873 --- /dev/null +++ b/src/json-collection-loader/cache.js @@ -0,0 +1,9 @@ +let cache + +export function cleanCache() { + cache = [] +} + +cleanCache() + +export default cache diff --git a/src/json-collection-loader/index.js b/src/json-collection-loader/index.js new file mode 100644 index 000000000..16a493bfc --- /dev/null +++ b/src/json-collection-loader/index.js @@ -0,0 +1,34 @@ +import loaderUtils from "loader-utils" + +import cache from "./cache" + +function relativePath(path) { + // remove cwd from resource path to relative path + const cwd = process.cwd() + if (path.indexOf(cwd) === 0) { + path = path.substr(cwd.length + 1) + } + + return path +} + +export default function(input) { + this.cacheable() + + const query = loaderUtils.parseQuery(this.query) + const options = query.jsonCollection + ? query.jsonCollection + : this.options.jsonCollection + ? this.options.jsonCollection + : { + urlify: (url) => url, + } + + cache.push({ + __filename: relativePath(this.resourcePath), + __url: options.urlify(relativePath(this.resourcePath)), + ...JSON.parse(input).head, + }) + + return input +} diff --git a/src/json-collection-loader/package.json b/src/json-collection-loader/package.json new file mode 100644 index 000000000..12b499d8b --- /dev/null +++ b/src/json-collection-loader/package.json @@ -0,0 +1,13 @@ +{ + "name": "json-collection", + "version": "1.0.0", + "author": "Maxime Thirouin", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/MoOx/json-collection-loader" + }, + "dependencies": { + "loader-utils": "^0.2.11" + } +} diff --git a/src/json-collection-loader/plugin.js b/src/json-collection-loader/plugin.js new file mode 100644 index 000000000..e570d2bfc --- /dev/null +++ b/src/json-collection-loader/plugin.js @@ -0,0 +1,28 @@ +import fs from "fs" +import path from "path" +import cache, { cleanCache } from "./cache" + +let lastOutput + +function jsonCollectionPlugin(options) { + return function() { + this.plugin("done", () => { + const jsonCollection = JSON.stringify(cache, null, 2) + if (lastOutput !== jsonCollection) { + lastOutput = jsonCollection + fs.writeFile( + path.join(this.outputPath, options.filename), + jsonCollection, + (err) => { + if (err) { + throw err + } + cleanCache() + } + ) + } + }) + } +} + +export default jsonCollectionPlugin diff --git a/src/markdown-as-json-loader/index.js b/src/markdown-as-json-loader/index.js new file mode 100644 index 000000000..ada9ab457 --- /dev/null +++ b/src/markdown-as-json-loader/index.js @@ -0,0 +1,51 @@ +/* +Example + +```md + --- + title: Test + key: value + --- + + _Md_ content +``` + +return a object: + +```json +{ + head: { + title: "Test", + key: "value", + }, + body: "Md content", + rawBody: "_Md_ content", + raw: {initial content}, +} + +``` + */ +import loaderUtils from "loader-utils" + +import yamlHeaderParser from "gray-matter" +import markdownIt from "markdown-it" + +export default function(source) { + this.cacheable() + + const query = loaderUtils.parseQuery(this.query) + const md = query.markdownIt + ? query.markdownIt + : this.options.markdownIt + ? this.options.markdownIt + : markdownIt() + + const parsed = yamlHeaderParser(source) + + return JSON.stringify({ + head: parsed.data, + body: md.render(parsed.content), + rawBody: parsed.content, + raw: parsed.orig, + }) +} diff --git a/src/markdown-as-json-loader/package.json b/src/markdown-as-json-loader/package.json new file mode 100644 index 000000000..81fe2d35a --- /dev/null +++ b/src/markdown-as-json-loader/package.json @@ -0,0 +1,13 @@ +{ + "name": "markdown-as-json", + "version": "1.0.0", + "author": "Maxime Thirouin", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/MoOx/markdown-as-json-loader" + }, + "dependencies": { + "loader-utils": "^0.2.11" + } +} diff --git a/src/to-static-html/Html.js b/src/to-static-html/Html.js new file mode 100644 index 000000000..ec6595ffe --- /dev/null +++ b/src/to-static-html/Html.js @@ -0,0 +1,47 @@ +import React, { Component } from "react" +import { PropTypes } from "react" + +export default class Html extends Component { + + static propTypes = { + body: PropTypes.string.isRequired, + store: PropTypes.object.isRequired, + } + + render() { + return ( + + + + + + + +
+ + + + ) + } +} diff --git a/src/to-static-html/index.js b/src/to-static-html/index.js new file mode 100644 index 000000000..f83af0ebf --- /dev/null +++ b/src/to-static-html/index.js @@ -0,0 +1,88 @@ +import fs from "fs" +import path from "path" +import mkdirp from "mkdirp" + +import urlAsHtml from "./url-as-html" + +import createStore from "../createStore" + +// import collection from "../json-collection-loader/cache" + +// react-router beta4 +// import { createRoutes } from "react-router/lib/RouteUtils" + +export default ({ urls, source, dest, exports }) => { + + const store = createStore(exports.reducers, exports.initialState) + + const routes = + // react-router beta4 + // createRoutes( + exports.routes + // ) + + // create all html files + return Promise.all( + urls.map( + (url) => { + const basename = path.join(dest, url) + let data + try { + data = require(path.join(basename, "index")) + } + /* eslint-disable no-empty */ + catch (err) { + // no + // console.info(`No data for url '${ url }'.`) + } + /* eslint-enable no-empty */ + + if (data) { + // prepare page data + store.dispatch({ + type: "PAGE_SET", + page: url, + response: { + data, + }, + }) + } + + return ( + urlAsHtml(url, { routes, store }) + .then( + (html) => { + return new Promise((resolve, reject) => { + const filename = path.join(basename, "index.html") + // console.log(basename, filename) + + mkdirp(basename, (err) => { + // console.log("mkdir done", basename, err) + if (err) { + reject(err) + } + + fs.writeFile(filename, html, (error) => { + // console.log("fs.writeFile done", filename, err) + if (error) { + reject(error) + } + + // forget page data to avoid having all pages data in all + // pages + store.dispatch({ + type: "PAGE_FORGET", + page: url, + }) + + resolve(filename) + }) + }) + }) + } + ) + ) + } + ) + ) +} diff --git a/src/to-static-html/url-as-html.js b/src/to-static-html/url-as-html.js new file mode 100644 index 000000000..a2b30b3c5 --- /dev/null +++ b/src/to-static-html/url-as-html.js @@ -0,0 +1,67 @@ +import React from "react" + +// react-router beta4 +// import { useRoutes, RoutingContext } from "react-router" +// import createHistory from "history/lib/createMemoryHistory" +// import createLocation from "history/lib/createLocation" +import Router from "react-router" +import Location from "react-router/lib/Location" + +import { Provider } from "react-redux" + +import Html from "./Html" + +export default (url, { routes, store }) => new Promise((resolve, reject) => { + + try { + const location = new Location(url) + // react-router beta4 + // https://github.com/rackt/react-router/issues/1793 + // const location = createLocation(url) + // const history = useRoutes(createHistory)({ routes }) + // history.match( + // react-router beta3 + Router.run( + routes, + location, + (error, state) => { + if (error) { + return reject(error) + } + + // write htmlString as html files + return resolve( + // render html document as simple html + "" + + React.renderToStaticMarkup( + React.createElement(Html, { + + // but render app body as "react"ified html (with data-react-id) + body: React.renderToString( + // the wrapper is used here because the client might have the + // devtools at the same level as the + // the