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