├── .eslintrc ├── .gitignore ├── README.md ├── app ├── actions │ └── entries.js ├── entry.js ├── projectRequire.js ├── reducers │ └── entries.js ├── routes.js ├── store.js ├── util │ ├── apiRequest.js │ ├── createAsyncAction.js │ └── createAsyncActionHandlers.js └── views │ ├── App.js │ ├── Entry.js │ └── Index.js ├── bin ├── peridot └── peridot-build-pages ├── integration └── template.test.js ├── package.json ├── src ├── cli │ ├── commands │ │ ├── build.js │ │ ├── new.js │ │ └── serve.js │ ├── index.js │ └── optTypes.js ├── core │ ├── build │ │ ├── BuilderManager.js │ │ ├── builders │ │ │ ├── AbstractBuilder.js │ │ │ ├── CopyBuilder.js │ │ │ ├── EntriesBuilder.js │ │ │ ├── PagesBuilder.js │ │ │ └── WebpackBuilder.js │ │ └── index.js │ ├── entries │ │ ├── Entry.js │ │ ├── buildEntries.js │ │ └── media │ │ │ ├── Media.js │ │ │ ├── fetchMediaQueue.js │ │ │ ├── getMedia.js │ │ │ ├── photos │ │ │ ├── Photo.js │ │ │ ├── getConverter.js │ │ │ ├── getPhoto.js │ │ │ └── photoCache.js │ │ │ └── twitter │ │ │ ├── Tweet.js │ │ │ ├── fetchTweets.js │ │ │ ├── getToken.js │ │ │ ├── serializeTweet.js │ │ │ └── tweetCache.js │ ├── generateWebpackConfig.js │ └── watch.js ├── pagesBuilder │ ├── index.js │ ├── injectGlobals.js │ ├── monkeyPatchModuleLoading.js │ └── render │ │ ├── renderEntries.js │ │ ├── renderList.js │ │ ├── renderPage.js │ │ └── renderRoute.js ├── settings.js └── util │ ├── errorWrap.js │ ├── exists.js │ ├── promiseWrap.js │ └── requireFromProject.js └── template ├── README.md ├── _entries.yml ├── _private.example.yml ├── _settings.yml ├── app ├── actions │ └── .gitkeep ├── components │ ├── Loading.js │ └── layouts │ │ ├── List.js │ │ ├── Page.js │ │ ├── Post.js │ │ └── Wrapper.js ├── frontendEntry.js └── reducers │ └── index.js ├── eslintrc ├── gitignore ├── package.json ├── public └── .gitkeep ├── styles ├── app.css └── writ.css └── webpack.config.js /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "env": { 4 | "browser": true, 5 | "node": true 6 | }, 7 | "rules": { 8 | // needed for eslint 9 | "strict": 0, 10 | 11 | // style stuff 12 | "quotes": [2, "single"], 13 | "react/jsx-quotes": [1, 'double', 'avoid-escape'], 14 | "no-underscore-dangle": [0], 15 | "comma-dangle": 0, 16 | "camelcase": 0, 17 | "dot-notation": 0, 18 | 19 | // functionality stuff 20 | "new-cap": 0, 21 | "no-shadow": 0, 22 | 23 | // react stuff 24 | "react/jsx-boolean-value": 1, 25 | "react/jsx-no-undef": 2, 26 | "react/jsx-uses-react": 2, 27 | "react/jsx-uses-vars": 1, 28 | "react/no-did-mount-set-state": 1, 29 | "react/no-did-update-set-state": 1, 30 | "react/no-unknown-property": 1, 31 | "react/react-in-jsx-scope": 1, 32 | "react/self-closing-comp": 1, 33 | "react/wrap-multilines": 1, 34 | 35 | "no-process-exit": 0 36 | }, 37 | "ecmaFeatures": { 38 | "jsx": true 39 | }, 40 | "plugins": [ 41 | 'react' 42 | ] 43 | } 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | temp/ 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Peridot 2 | 3 | Peridot is an isomorphic static site generator powered by Node and React. Its goal is to make it easy to make a "digital scrapbook" out of various media you have, from photos to Tweets. It's pretty far from this overall goal, but malleable enough that you can still build a pretty cool site with it. 4 | 5 | Instead of traditional template-based static site generators like Jekyll, Peridot sites are built with React components, which allow you to easily build full-featured sites, instead of being limited to various plugins and template filters. These components are rendered by the Peridot build tool, creating static pages, as well as a bundle of client-side JS that will take over after the initial page render. This allows you to build powerful single-page user-experiences while retaining the advantages of server-rendered applications (such as fast load times and SEO). 6 | 7 | ## Example 8 | 9 | This generator powers [loudplaces.disco.zone](http://loudplaces.disco.zone). That site's source is [available on my GitHub](https://github.com/thomasboyt/loudplaces.disco.zone). 10 | 11 | ## Installation 12 | 13 | ``` 14 | npm install -g peridot 15 | ``` 16 | 17 | ## CLI Usage 18 | 19 | ### `peridot new ` 20 | 21 | Generate a new blog at ``. 22 | 23 | ### `peridot build [--optimize]` 24 | 25 | Build your blog to `_site/`. Optionally minify your client-side app bundle by passing `--optimize`. 26 | 27 | ### `peridot serve [--port=3000]` 28 | 29 | Serve your site at `localhost:[port]`. This will automatically rebuild your client-side bundle and rendered pages when you change files in your project. 30 | 31 | ## Developing Your Site 32 | 33 | *(this whole section is obviously a to-do at the moment. The [source for loudplaces.disco.zone](https://github.com/thomasboyt/loudplaces.disco.zone) may be a useful resource if you want to experiment with Peridot)* 34 | 35 | ### \_entries.yml 36 | 37 | Entries have the following default fields. Fields marked with an asterisk (\*) are in the shortened version of an entry used for the post list views. 38 | 39 | * \* `title`: The title of the entry 40 | * \* `date`: The date of the entry. Should be a string in the format `YYYY-MM-DD`. 41 | * \* `location`: The location of the entry. 42 | * `body`: The body of the entry. This is parsed and rendered as Markdown. 43 | * `media`: A list of media (see [Using Media](#using-media)) 44 | 45 | Only the `title` and `date` fields are required. 46 | 47 | #### hasMedia & hasBody 48 | 49 | Two additional fields, `hasMedia` and `hasBody`, are automatically added to the serialized `post` received by the `` and `` components. If there is media or a post body present, the respective attribute will be `true`; otherwise it's set to `false`. 50 | 51 | #### Custom fields 52 | 53 | You an add additional custom fields, which will be passed as part of the `post` object received in the `` component. They will not be available as part of the "short-form" post objects passed to the `` component (see [this issue](https://github.com/thomasboyt/peridot/issues/38)). 54 | 55 | ### Customizing Components 56 | 57 | ### Custom Assets With Webpack 58 | 59 | ## Using Media 60 | 61 | While you can use any form of media you want in your client-side application, Peridot has special tools for caching and hosting certain media. For example, Tweets can be cached and saved as part of your blog's data, which allows you to render them how you want on the client and server without any dependency on Twitter's JavaScript APIs. 62 | 63 | ### Photos 64 | 65 | Peridot can import photos from your local filesystem during its build process, resizing and caching them for future display. This requires you to have either [GraphicsMagick](http://www.graphicsmagick.org/) or [ImageMagick](http://www.imagemagick.org/script/index.php) installed. On OSX, you can install them with [Homebrew](http://brew.sh/). 66 | 67 | For example, in `_entries.yml`, the following `media`: 68 | 69 | ```yaml 70 | media: 71 | - photo: speedy/IMG_1119.jpg 72 | caption: Aye Nako in action 73 | ``` 74 | 75 | Will import a file from `project_directory/photos/speedy/IMG_1119.jpg`. 76 | 77 | The photo will be resized to whatever sizes you have defined in your project's `_settings.yml` file. You can access the URL for each size on `media.data.sizes` in your app. For example, if you defined the following sizes in `_settings.yml`: 78 | 79 | ```yaml 80 | photos: 81 | sizes: 82 | large: 83 | w: 1024 84 | h: 1024 85 | thumb: 86 | w: 250 87 | h: 250 88 | ``` 89 | 90 | You could then render the thumbnail, with a link to the large image, like so: 91 | 92 | ```js 93 | const Post = React.createClass({ 94 | 95 | // ... 96 | 97 | renderMedia() { 98 | const media = this.props.post.media; 99 | 100 | return media.map((media) => { 101 | if (media.type === 'photo') { 102 | return ( 103 | 104 | 105 |

{media.data.caption}

106 |
107 | ); 108 | } 109 | }); 110 | }, 111 | 112 | // ... 113 | }); 114 | ``` 115 | 116 | ### Tweets 117 | -------------------------------------------------------------------------------- /app/actions/entries.js: -------------------------------------------------------------------------------- 1 | import apiRequest from '../util/apiRequest'; 2 | import createAsyncAction from '../util/createAsyncAction'; 3 | 4 | export const FETCH_ENTRY = 'fetchEntry'; 5 | export const FETCH_ENTRIES_LIST = 'fetchEntriesList'; 6 | 7 | export const fetchEntry = createAsyncAction(FETCH_ENTRY, async (slug) => { 8 | const data = await apiRequest(`/entries/${slug}/data.json`); 9 | 10 | return { 11 | slug: slug, 12 | entry: data 13 | }; 14 | }); 15 | 16 | export const fetchEntriesList = createAsyncAction(FETCH_ENTRIES_LIST, async () => { 17 | const data = await apiRequest('/entries.json'); 18 | 19 | return { 20 | entries: data 21 | }; 22 | }); 23 | -------------------------------------------------------------------------------- /app/entry.js: -------------------------------------------------------------------------------- 1 | // Polyfills window.fetch 2 | import 'whatwg-fetch'; 3 | 4 | import React from 'react'; 5 | import Router, {HistoryLocation} from 'react-router'; 6 | import {Provider} from 'react-redux'; 7 | 8 | import createStore from './store'; 9 | import routes from './routes'; 10 | 11 | /* global __PROJECT__ */ 12 | require(__PROJECT__ + '/app/frontendEntry'); 13 | 14 | const mountPoint = document.getElementById('mount-point'); 15 | 16 | const store = createStore(window.__data__); 17 | 18 | Router.run(routes, HistoryLocation, (Root, routerState) => { 19 | React.render(( 20 | 21 | {() => } 22 | 23 | ), mountPoint); 24 | }); 25 | -------------------------------------------------------------------------------- /app/projectRequire.js: -------------------------------------------------------------------------------- 1 | /* 2 | * For __PROJECT__ source, see: 3 | * Node: src/injectGlobals 4 | * Webpack: src/builder/generateWebpackConfig 5 | * 6 | * Thanks sokra: https://gist.github.com/thomasboyt/736713bc677124e57936#gistcomment-1551872 7 | */ 8 | 9 | /* global __PROJECT__ */ 10 | const List = require(__PROJECT__ + '/app/components/layouts/List'); 11 | const Post = require(__PROJECT__ + '/app/components/layouts/Post'); 12 | const Wrapper = require(__PROJECT__ + '/app/components/layouts/Wrapper'); 13 | const reducers = require(__PROJECT__ + '/app/reducers'); 14 | 15 | export {List, Post, Wrapper, reducers}; 16 | -------------------------------------------------------------------------------- /app/reducers/entries.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | 3 | import {handleActions} from 'redux-actions'; 4 | import createAsyncActionHandlers from '../util/createAsyncActionHandlers'; 5 | 6 | import { 7 | FETCH_ENTRY, 8 | FETCH_ENTRIES_LIST 9 | } from '../actions/entries'; 10 | 11 | const initialState = { 12 | // Holds the list of entries 13 | entries: [], 14 | 15 | // Map slug -> hydration state 16 | hydratedEntries: {}, 17 | 18 | // Whether the full entry list has been hydrated or not 19 | hydratedList: false 20 | }; 21 | 22 | const fetchEntryHandlers = createAsyncActionHandlers(FETCH_ENTRY, (state, data) => { 23 | const {slug, entry} = data; 24 | 25 | // Replace entry by slug 26 | // TODO: Dis slow. 27 | const entries = state.entries.map((storedEntry) => { 28 | if (storedEntry.slug === slug) { 29 | return entry; 30 | } 31 | 32 | return storedEntry; 33 | }); 34 | 35 | // Update map of hydrated entries: 36 | const hydratedEntries = Object.assign({}, state.hydratedEntries); 37 | hydratedEntries[slug] = true; 38 | 39 | return {entries, hydratedEntries}; 40 | }); 41 | 42 | const fetchListHandlers = createAsyncActionHandlers(FETCH_ENTRIES_LIST, (state, data) => { 43 | // Merge existing hydrated entries into entry list 44 | const entries = data.entries.map((entry) => { 45 | if (state.hydratedEntries[entry.slug]) { 46 | return _.find(state.entries, {slug: entry.slug}); 47 | } 48 | 49 | return entry; 50 | }); 51 | 52 | return { 53 | entries, 54 | hydratedList: true 55 | }; 56 | }); 57 | 58 | export default handleActions(Object.assign( 59 | fetchEntryHandlers, 60 | fetchListHandlers 61 | ), initialState); 62 | -------------------------------------------------------------------------------- /app/routes.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {Route, DefaultRoute} from 'react-router'; 3 | 4 | import App from './views/App'; 5 | import Entry from './views/Entry'; 6 | import Index from './views/Index'; 7 | 8 | const routes = ( 9 | 10 | 11 | 12 | 13 | ); 14 | 15 | export default routes; 16 | -------------------------------------------------------------------------------- /app/store.js: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware, combineReducers } from 'redux'; 2 | import promiseMiddleware from 'redux-promise-middleware'; 3 | 4 | const createStoreWithMiddleware = applyMiddleware( 5 | promiseMiddleware 6 | )(createStore); 7 | 8 | import entries from './reducers/entries'; 9 | import {reducers as customReducers} from './projectRequire'; 10 | 11 | const appReducers = combineReducers(Object.assign({entries}, customReducers)); 12 | 13 | export default function createAppStore(data) { 14 | return createStoreWithMiddleware(appReducers, data); 15 | } 16 | -------------------------------------------------------------------------------- /app/util/apiRequest.js: -------------------------------------------------------------------------------- 1 | export default async function apiRequest(...args) { 2 | const resp = await window.fetch(...args); 3 | 4 | if (resp.status !== 200) { 5 | throw new Error(resp); 6 | } 7 | 8 | return await resp.json(); 9 | } 10 | -------------------------------------------------------------------------------- /app/util/createAsyncAction.js: -------------------------------------------------------------------------------- 1 | export default function asyncAction(name, cb) { 2 | return function(...args) { 3 | return { 4 | types: [ 5 | name + 'Pending', 6 | name + 'Fulfilled', 7 | name + 'Rejected' 8 | ], 9 | payload: { 10 | promise: cb(...args) 11 | } 12 | }; 13 | }; 14 | } 15 | 16 | -------------------------------------------------------------------------------- /app/util/createAsyncActionHandlers.js: -------------------------------------------------------------------------------- 1 | export default function createAsyncActionHandlers(name, onSuccess) { 2 | function onPending(state) { 3 | const changes = {}; 4 | 5 | changes[name + 'Pending'] = true; 6 | changes[name + 'Error'] = null; 7 | 8 | return Object.assign({}, state, changes); 9 | } 10 | 11 | function onRejected(state, action) { 12 | const changes = {}; 13 | 14 | changes[name + 'Pending'] = false; 15 | changes[name + 'Error'] = action.error; 16 | 17 | return Object.assign({}, state, changes); 18 | } 19 | 20 | function onFulfilled(state, action) { 21 | const changes = onSuccess(state, action.payload); 22 | 23 | changes[name + 'Pending'] = false; 24 | changes[name + 'Error'] = null; 25 | 26 | return Object.assign({}, state, changes); 27 | } 28 | 29 | const actions = {}; 30 | 31 | actions[name + 'Pending'] = onPending; 32 | actions[name + 'Fulfilled'] = onFulfilled; 33 | actions[name + 'Rejected'] = onRejected; 34 | 35 | return actions; 36 | } 37 | 38 | -------------------------------------------------------------------------------- /app/views/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {RouteHandler} from 'react-router'; 3 | import {Wrapper} from '../projectRequire'; 4 | 5 | const App = React.createClass({ 6 | contextTypes: { 7 | router: React.PropTypes.func.isRequired 8 | }, 9 | 10 | render() { 11 | const name = this.context.router.getCurrentPath(); 12 | 13 | return ( 14 | 15 | 16 | 17 | ); 18 | } 19 | }); 20 | 21 | export default App; 22 | -------------------------------------------------------------------------------- /app/views/Entry.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import {connect} from 'react-redux'; 4 | import {fetchEntry} from '../actions/entries'; 5 | 6 | import {Post} from '../projectRequire'; 7 | 8 | const Entry = React.createClass({ 9 | propTypes: { 10 | entries: React.PropTypes.array.isRequired, 11 | isLoading: React.PropTypes.bool, 12 | fetchError: React.PropTypes.object 13 | }, 14 | 15 | componentWillMount() { 16 | const {dispatch} = this.props; 17 | const {slug} = this.props.params; 18 | 19 | if (!this.isHydrated()) { 20 | dispatch(fetchEntry(slug)); 21 | } 22 | }, 23 | 24 | isHydrated() { 25 | const {slug} = this.props.params; 26 | return !!(this.props.hydratedEntries[slug]); 27 | }, 28 | 29 | getCurrentEntry() { 30 | const {entries} = this.props; 31 | const {slug} = this.props.params; 32 | 33 | return entries.filter((entry) => entry.slug === slug)[0]; 34 | }, 35 | 36 | render() { 37 | const {fetchError} = this.props; 38 | 39 | const entry = this.getCurrentEntry(); 40 | 41 | return ( 42 | 45 | ); 46 | } 47 | }); 48 | 49 | function getState(state) { 50 | const entriesState = state.entries; 51 | 52 | return { 53 | entries: entriesState.entries, 54 | fetchError: entriesState.fetchEntryError, 55 | hydratedEntries: entriesState.hydratedEntries 56 | }; 57 | } 58 | 59 | export default connect(getState)(Entry); 60 | -------------------------------------------------------------------------------- /app/views/Index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import {connect} from 'react-redux'; 4 | import {fetchEntriesList} from '../actions/entries'; 5 | 6 | import {List} from '../projectRequire'; 7 | 8 | const Index = React.createClass({ 9 | componentDidMount() { 10 | const {dispatch, hydratedList} = this.props; 11 | 12 | if (!hydratedList) { 13 | dispatch(fetchEntriesList()); 14 | } 15 | }, 16 | 17 | render() { 18 | const {entries, isLoading, fetchError} = this.props; 19 | 20 | return ( 21 | 22 | ); 23 | } 24 | }); 25 | 26 | function getState(state) { 27 | const entriesState = state.entries; 28 | 29 | return { 30 | entries: entriesState.entries, 31 | isLoading: entriesState.fetchEntriesListPending, 32 | fetchError: entriesState.fetchEntryError, 33 | hydratedList: entriesState.hydratedList 34 | }; 35 | } 36 | 37 | export default connect(getState)(Index); 38 | -------------------------------------------------------------------------------- /bin/peridot: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | require('babel/register')({ 4 | optional: ['es7.asyncFunctions'] 5 | }); 6 | 7 | require('../src/cli'); 8 | -------------------------------------------------------------------------------- /bin/peridot-build-pages: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | require('babel/register')({ 4 | optional: ['es7.asyncFunctions'] 5 | }); 6 | 7 | require('../src/pagesBuilder/monkeyPatchModuleLoading'); 8 | require('../src/pagesBuilder/injectGlobals'); 9 | 10 | require('../src/pagesBuilder')(); 11 | -------------------------------------------------------------------------------- /integration/template.test.js: -------------------------------------------------------------------------------- 1 | /*eslint-env mocha */ 2 | 3 | import expect from 'expect'; 4 | import {spawnSync} from 'child_process'; 5 | import path from 'path'; 6 | import {sync as mkdirpSync} from 'mkdirp'; 7 | import {sync as rimrafSync} from 'rimraf'; 8 | 9 | import exists from '../src/util/exists'; 10 | 11 | /* 12 | * Really dumb integration test. 13 | * 14 | * 1. `peridot new temp-folder` 15 | * 2. `peridot build` 16 | * 3. exit code 0 = pass 17 | */ 18 | 19 | const peridotPath = path.join(__dirname, '../bin/peridot'); 20 | 21 | const projectPath = `${path.join(process.cwd(), 'temp/')}`; 22 | describe('Peridot template', function() { 23 | this.timeout(0); 24 | 25 | it('can be created and built', () => { 26 | if (exists(projectPath)) { 27 | rimrafSync(projectPath); 28 | } 29 | 30 | mkdirpSync(projectPath); 31 | 32 | const newProc = spawnSync(peridotPath, ['new', projectPath, '--force', '--npm-install'], { 33 | stdio: ['ignore', process.stdout, process.stderr], 34 | encoding: 'utf-8' 35 | }); 36 | expect(newProc.status).toEqual(0); 37 | 38 | const buildProc = spawnSync(peridotPath, ['build'], { 39 | cwd: projectPath, 40 | stdio: 'inherit', 41 | encoding: 'utf-8' 42 | }); 43 | expect(buildProc.status).toEqual(0); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "peridot", 3 | "version": "0.2.0", 4 | "description": "A static social media scrapbook generator powered by React.", 5 | "main": "bin/peridot", 6 | "repository": "thomasboyt/peridot", 7 | "scripts": { 8 | "test": "mocha --no-color --compilers js:babel/register integration/**/*.test.js" 9 | }, 10 | "bin": { 11 | "peridot": "bin/peridot" 12 | }, 13 | "author": "Thomas Boyt ", 14 | "license": "MIT", 15 | "dependencies": { 16 | "babel": "^5.8.21", 17 | "babel-loader": "^5.3.2", 18 | "babel-runtime": "^5.8.20", 19 | "chalk": "^1.1.1", 20 | "chokidar": "^1.0.5", 21 | "commander": "^2.8.1", 22 | "cpr": "^0.4.1", 23 | "elegant-spinner": "^1.0.0", 24 | "es6-promise": "^3.0.2", 25 | "express": "^4.13.3", 26 | "fs-extra": "^0.24.0", 27 | "gm": "^1.18.1", 28 | "inquirer": "^0.9.0", 29 | "js-yaml": "^3.3.1", 30 | "lodash": "^3.10.1", 31 | "log-update": "^1.0.0", 32 | "mkdirp": "^0.5.1", 33 | "node-fetch": "^1.3.2", 34 | "path-is-inside": "^1.0.1", 35 | "react": "^0.13.3", 36 | "react-document-title": "^1.0.3", 37 | "react-redux": "^2.1.0", 38 | "react-router": "^0.13.3", 39 | "recursive-readdir": "^1.2.1", 40 | "redux": "^2.0.0", 41 | "redux-actions": "^0.7.0", 42 | "redux-promise-middleware": "^0.2.1", 43 | "remarkable": "^1.6.0", 44 | "slug": "^0.9.1", 45 | "webpack": "^1.11.0", 46 | "webpack-merge": "^0.1.3", 47 | "whatwg-fetch": "^0.9.0" 48 | }, 49 | "devDependencies": { 50 | "expect": "^1.10.0", 51 | "mocha": "^2.3.2", 52 | "rimraf": "^2.4.3" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/cli/commands/build.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | 3 | import build from '../../core/build'; 4 | 5 | export default async function buildCmd(options = {}) { 6 | const builders = { 7 | entries: !options.skipEntries, 8 | pages: !options.skipPages, 9 | copy: !options.skipCopy, 10 | webpack: !options.skipWebpack 11 | }; 12 | 13 | const enabledBuilders = _.filter(_.keys(builders), (key) => builders[key] === true); 14 | 15 | await build(enabledBuilders, options); 16 | } 17 | -------------------------------------------------------------------------------- /src/cli/commands/new.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import {readFileSync, writeFileSync} from 'fs'; 3 | import {sync as mkdirpSync} from 'mkdirp'; 4 | import {execSync} from 'child_process'; 5 | 6 | import {Promise} from 'es6-promise'; 7 | import recursive from 'recursive-readdir'; 8 | import inquirer from 'inquirer'; 9 | import {copySync} from 'fs-extra'; 10 | 11 | import exists from '../../util/exists'; 12 | import promiseWrap from '../../util/promiseWrap'; 13 | 14 | const templateDir = path.join(__dirname, '../../../template'); 15 | 16 | const recursiveP = promiseWrap(recursive); 17 | 18 | function showPrompt(...args) { 19 | return new Promise((resolve) => { 20 | inquirer.prompt(...args, (answers) => resolve(answers)); 21 | }); 22 | } 23 | 24 | export default async function(outPath, options) { 25 | if (!options.force) { 26 | if (exists(outPath)) { 27 | console.log(`Destination ${outPath} already exists; refusing to build.`); 28 | console.log('Use --force to override.'); 29 | return; 30 | } 31 | } 32 | 33 | // Read template files 34 | const files = await recursiveP(templateDir); 35 | 36 | files.forEach((file) => { 37 | const content = readFileSync(file, {encoding: 'utf-8'}); 38 | 39 | const relPath = path.relative(templateDir, file); 40 | const dirname = path.dirname(relPath); 41 | 42 | let basename = path.basename(relPath); 43 | 44 | // TODO: would be nice to have a real convention for this 45 | if (basename === 'gitignore') { 46 | basename = '.gitignore'; 47 | } else if (basename === 'eslintrc') { 48 | basename = '.eslintrc'; 49 | } 50 | 51 | const dirOutPath = path.join(path.resolve(process.cwd(), outPath), dirname); 52 | const fileOutPath = path.join(dirOutPath, basename); 53 | 54 | mkdirpSync(dirOutPath); 55 | 56 | writeFileSync(fileOutPath, content, {encoding: 'utf-8'}); 57 | }); 58 | 59 | const fullPath = path.resolve(outPath); 60 | 61 | copySync(path.join(fullPath, '_private.example.yml'), path.join(fullPath, '_private.yml')); 62 | 63 | console.log(`Created new Peridot project in ${fullPath}.`); 64 | 65 | let shouldNPMInstall = options.npmInstall; 66 | 67 | if (process.stdin.isTTY) { 68 | shouldNPMInstall = await showPrompt([{ 69 | type: 'confirm', 70 | name: 'shouldNPMInstall', 71 | message: 'Run npm install?', 72 | default: true 73 | }]).shouldNPMInstall; 74 | } 75 | 76 | if (shouldNPMInstall) { 77 | execSync('npm install', { 78 | cwd: fullPath, 79 | stdio: 'inherit' 80 | }); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/cli/commands/serve.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | 3 | import build from './build'; 4 | import watch from '../../core/watch'; 5 | 6 | export default async function serve(options) { 7 | await build({ 8 | skipCopy: true 9 | }); 10 | 11 | const app = express(); 12 | 13 | app.use(express.static('_site/')); 14 | app.use(express.static('public/')); 15 | app.use('/assets/photos', express.static('_cache/photos/')); 16 | 17 | const server = app.listen(options.port, () => { 18 | const host = server.address().address; 19 | const port = server.address().port; 20 | 21 | console.log(`Listening at http://${host}:${port}\n`); 22 | }); 23 | 24 | watch(); 25 | } 26 | -------------------------------------------------------------------------------- /src/cli/index.js: -------------------------------------------------------------------------------- 1 | import app from 'commander'; 2 | 3 | import errorWrap from '../util/errorWrap'; 4 | import {intOpt} from './optTypes'; 5 | 6 | const pkg = require('../../package.json'); 7 | 8 | app 9 | .version(pkg.version); 10 | 11 | app.command('new ') 12 | .description('Create new blog using the default template at [path]') 13 | .option('-f, --force', 'Overwrite existing files at [path]') 14 | .option('--npm-install', 'Run NPM install') 15 | .action((...args) => { 16 | const generate = require('./commands/new'); 17 | errorWrap(generate, ...args); 18 | }); 19 | 20 | app.command('build') 21 | .description('Build files to _site/') 22 | .option('-o, --optimize', 'minify Webpack bundle') 23 | .option('--skip-webpack', 'don\'t build frontend assets through webpack') 24 | .option('--skip-pages', 'don\'t build static HTML or JSON') 25 | .option('--skip-copy', 'don\'t copy static files to _site/') 26 | .option('--log-webpack', 'log Webpack stats to webpack.log.json') 27 | .action((...args) => { 28 | const build = require('./commands/build'); 29 | errorWrap(build, ...args); 30 | }); 31 | 32 | app.command('serve') 33 | .description('Build and serve files') 34 | .option('-p, --port ', 'port to serve on (defaults to 3000)', intOpt('port'), 3000) 35 | .action((...args) => { 36 | const serve = require('./commands/serve'); 37 | errorWrap(serve, ...args); 38 | }); 39 | 40 | app.parse(process.argv); 41 | 42 | // No subcommand was passed 43 | if (!process.argv.slice(2).length) { 44 | app.outputHelp(); 45 | } 46 | -------------------------------------------------------------------------------- /src/cli/optTypes.js: -------------------------------------------------------------------------------- 1 | export function intOpt(name) { 2 | return function(val, defaultVal) { 3 | const parsed = parseInt(val, 10); 4 | 5 | if (Number.isNaN(parsed)) { 6 | console.error(`Invalid option ${name} = ${val}: should be an integer (e.g. ${defaultVal})`); 7 | process.exit(); 8 | } 9 | 10 | return parsed; 11 | }; 12 | } 13 | -------------------------------------------------------------------------------- /src/core/build/BuilderManager.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | 3 | import logUpdate from 'log-update'; 4 | import elegantSpinner from 'elegant-spinner'; 5 | import chalk from 'chalk'; 6 | 7 | import PagesBuilder from './builders/PagesBuilder'; 8 | import WebpackBuilder from './builders/WebpackBuilder'; 9 | import CopyBuilder from './builders/CopyBuilder'; 10 | import EntriesBuilder from './builders/EntriesBuilder'; 11 | 12 | const builders = { 13 | entries: EntriesBuilder, 14 | pages: PagesBuilder, 15 | copy: CopyBuilder, 16 | webpack: WebpackBuilder 17 | }; 18 | 19 | const spinner = elegantSpinner(); 20 | 21 | export default class BuilderManager { 22 | constructor(enabledBuilders=[]) { 23 | if (!enabledBuilders.length) { 24 | throw new Error('At least one builder must be specified'); 25 | } 26 | 27 | this.builders = {}; 28 | 29 | for (let name of enabledBuilders) { 30 | const Builder = builders[name]; 31 | 32 | if (!Builder) { 33 | throw new Error(`No builder named ${name}`); 34 | } 35 | 36 | this.builders[name] = new Builder(); 37 | } 38 | } 39 | 40 | async build(options) { 41 | this.renderProgress(); 42 | 43 | this._startRender(); 44 | 45 | await* _.map(this.builders, async (builder) => { 46 | const dependsOn = this.builders[builder.depends]; 47 | 48 | let waitFor; 49 | if (dependsOn) { 50 | builder.waiting = true; 51 | this.renderProgress(); 52 | 53 | try { 54 | await dependsOn.waitFor(); 55 | } catch(err) { 56 | builder.didError = true; 57 | builder.waiting = false; 58 | this.renderProgress(); 59 | return; 60 | } 61 | 62 | builder.waiting = false; 63 | this.renderProgress(); 64 | } 65 | 66 | await builder.wrappedBuild(options, waitFor); 67 | 68 | this.renderProgress(); 69 | }); 70 | 71 | this._stopRender(); 72 | } 73 | 74 | _startRender() { 75 | this._renderInterval = setInterval(() => this.renderProgress(), 100); 76 | } 77 | 78 | _stopRender() { 79 | clearInterval(this._renderInterval); 80 | logUpdate.done(); 81 | } 82 | 83 | renderBuilderProgress(builder, frame) { 84 | let progress = ''; 85 | 86 | if (builder.done) { 87 | progress = chalk.green('Done!'); 88 | } else if (builder.didError) { 89 | progress = chalk.bold.red('Error'); 90 | } else if (!builder.waiting) { 91 | progress = frame; 92 | } 93 | 94 | if (builder.time !== null) { 95 | progress += ` (${builder.time} s)`; 96 | } 97 | 98 | return progress; 99 | } 100 | 101 | renderProgress() { 102 | const frame = spinner(); 103 | 104 | const lines = _.map(this.builders, (builder) => { 105 | const progress = this.renderBuilderProgress(builder, frame); 106 | 107 | return `${_.padLeft(_.capitalize(builder.description), 30)}... ${progress}`; 108 | }).join('\n'); 109 | 110 | logUpdate(lines); 111 | } 112 | 113 | renderErrors() { 114 | _.each(this.builders, (builder) => { 115 | builder.renderErrors(); 116 | }); 117 | } 118 | } 119 | 120 | -------------------------------------------------------------------------------- /src/core/build/builders/AbstractBuilder.js: -------------------------------------------------------------------------------- 1 | import EventEmitter from 'events'; 2 | 3 | export default class Builder { 4 | constructor() { 5 | this.done = false; 6 | this.didError = false; 7 | this.logMsgs = []; 8 | this.time = null; 9 | 10 | this._evt = new EventEmitter(); 11 | } 12 | 13 | log(msg) { 14 | this.logMsgs.push(msg); 15 | } 16 | 17 | waitFor() { 18 | return new Promise((resolve, reject) => { 19 | if (this.done) { 20 | resolve(); 21 | } else if (this.didError) { 22 | reject(); 23 | } 24 | 25 | this._evt.once('done', resolve); 26 | this._evt.once('err', reject); 27 | }); 28 | } 29 | 30 | async wrappedBuild(options) { 31 | const startTime = new Date(); 32 | 33 | try { 34 | await this.build(options); 35 | this.done = true; 36 | this._evt.emit('done'); 37 | 38 | } catch(err) { 39 | await this.handleError(err); 40 | this.didError = true; 41 | this._evt.emit('err'); 42 | } 43 | 44 | const endTime = new Date(); 45 | 46 | this.time = (endTime - startTime) / 1000; 47 | } 48 | 49 | async handleError(err) { 50 | if (err.stack) { 51 | // JS errors 52 | this.log(err.stack); 53 | 54 | } else { 55 | // Other error 56 | this.log(err); 57 | } 58 | } 59 | 60 | renderErrors() { 61 | if (this.didError) { 62 | console.error(`Unhandled error ${this.description}:`); 63 | 64 | for (let msg of this.logMsgs) { 65 | console.error(`${msg}`); 66 | } 67 | } 68 | } 69 | } 70 | 71 | -------------------------------------------------------------------------------- /src/core/build/builders/CopyBuilder.js: -------------------------------------------------------------------------------- 1 | import cpr from 'cpr'; 2 | import promiseWrap from '../../../util/promiseWrap'; 3 | 4 | import AbstractBuilder from './AbstractBuilder'; 5 | 6 | const cprP = promiseWrap(cpr); 7 | 8 | export default class CopyBuilder extends AbstractBuilder { 9 | constructor() { 10 | super(); 11 | 12 | this.description = 'copying files'; 13 | } 14 | 15 | async build(/*options*/) { 16 | await cprP('_cache/photos', '_site/assets/photos'); 17 | await cprP('public/', '_site/'); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/core/build/builders/EntriesBuilder.js: -------------------------------------------------------------------------------- 1 | import AbstractBuilder from './AbstractBuilder'; 2 | 3 | import buildEntries from '../../entries/buildEntries'; 4 | 5 | export default class EntriesBuilder extends AbstractBuilder { 6 | constructor() { 7 | super(); 8 | 9 | this.description = 'loading entries'; 10 | } 11 | 12 | async build(options) { 13 | await buildEntries(); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/core/build/builders/PagesBuilder.js: -------------------------------------------------------------------------------- 1 | import {join as pathJoin} from 'path'; 2 | import {spawn} from 'child_process'; 3 | 4 | import AbstractBuilder from './AbstractBuilder'; 5 | 6 | const binPath = pathJoin(__dirname, '../../../../bin/peridot-build-pages'); 7 | 8 | function spawnBuildPages() { 9 | return new Promise((resolve, reject) => { 10 | const proc = spawn(binPath, [], { 11 | stdio: ['pipe', 'pipe', 'pipe'] 12 | }); 13 | 14 | proc.on('exit', (code) => { 15 | if (code !== 0) { 16 | const err = proc.stderr.read() || ''; 17 | reject(err); 18 | } else { 19 | resolve(); 20 | } 21 | }); 22 | }); 23 | } 24 | 25 | export default class PagesBuilder extends AbstractBuilder { 26 | constructor() { 27 | super(); 28 | 29 | this.depends = 'entries'; 30 | this.description = 'building pages'; 31 | } 32 | 33 | async build(/*options*/) { 34 | await spawnBuildPages(); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/core/build/builders/WebpackBuilder.js: -------------------------------------------------------------------------------- 1 | import {writeFileSync} from 'fs'; 2 | import webpack from 'webpack'; 3 | import promiseWrap from '../../../util/promiseWrap'; 4 | 5 | import generateWebpackConfig from '../../generateWebpackConfig'; 6 | import AbstractBuilder from './AbstractBuilder'; 7 | 8 | let compiler; 9 | 10 | function runWebpack(optimize = false) { 11 | // Lazily instantiate the compiler on first run 12 | if (!compiler) { 13 | const config = generateWebpackConfig(optimize); 14 | compiler = webpack(config); 15 | } 16 | 17 | return promiseWrap(compiler.run.bind(compiler))(); 18 | } 19 | 20 | export default class WebpackBuilder extends AbstractBuilder { 21 | constructor() { 22 | super(); 23 | 24 | this.description = 'building Webpack bundle'; 25 | } 26 | 27 | async build(options) { 28 | const stats = await runWebpack(options.optimize); 29 | 30 | const jsonStats = stats.toJson(); 31 | 32 | if (options.logWebpack) { 33 | writeFileSync('./webpack.log.json', JSON.stringify(jsonStats, null, 2), {encoding: 'utf-8'}); 34 | } 35 | 36 | this.jsonStats = jsonStats; 37 | } 38 | 39 | renderErrors() { 40 | if (this.didError) { 41 | super.renderErrors(); 42 | 43 | } else { 44 | const jsonStats = this.jsonStats; 45 | 46 | if (jsonStats.errors.length > 0) { 47 | console.log('*** Webpack errors:'); 48 | 49 | jsonStats.errors.map((err) => { 50 | console.log(err); 51 | console.log(''); 52 | }); 53 | } 54 | 55 | if (jsonStats.warnings.length > 0) { 56 | console.log('*** Webpack warnings:'); 57 | 58 | jsonStats.warnings.map((err) => { 59 | console.log(err); 60 | console.log(''); 61 | }); 62 | } 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/core/build/index.js: -------------------------------------------------------------------------------- 1 | import BuilderManager from './BuilderManager'; 2 | 3 | export default async function build(enabledBuilders, options={}) { 4 | const manager = new BuilderManager(enabledBuilders); 5 | 6 | await manager.build(options); 7 | 8 | manager.renderErrors(); 9 | } 10 | -------------------------------------------------------------------------------- /src/core/entries/Entry.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import slug from 'slug'; 3 | import Remarkable from 'remarkable'; 4 | 5 | import getMedia from './media/getMedia'; 6 | 7 | const md = new Remarkable(); 8 | 9 | function getOrRaise(entry, attr) { 10 | if (!entry[attr]) { 11 | throw new Error(`Entry missing required attribute: ${attr}`); 12 | } 13 | 14 | return entry[attr]; 15 | } 16 | 17 | export default class Entry { 18 | constructor(entryData) { 19 | this.title = getOrRaise(entryData, 'title'); 20 | this.date = getOrRaise(entryData, 'date'); 21 | this.body = entryData.body || null; 22 | this.media = entryData.media || []; 23 | this.customData = _.omit(entryData, 'title', 'date', 'body', 'media'); 24 | 25 | this.mediaQueueIdxs = []; 26 | } 27 | 28 | renderBody() { 29 | if (this.body) { 30 | return md.render(this.body); 31 | } else { 32 | return null; 33 | } 34 | } 35 | 36 | getSlug() { 37 | return slug(`${this.date} ${this.title}`, {lower: true}); 38 | } 39 | 40 | getMediaQueue() { 41 | return this.mediaQueueIdxs.map((mediaIdx) => this.media[mediaIdx]); 42 | } 43 | 44 | async hydrateMedia() { 45 | for (let idx in this.media) { 46 | const media = getMedia(this.media[idx]); 47 | await media.hydrate(); 48 | 49 | if (!media.data) { 50 | this.mediaQueueIdxs.push(idx); 51 | } 52 | 53 | this.media[idx] = media; 54 | } 55 | } 56 | 57 | serialize() { 58 | return { 59 | title: this.title, 60 | date: this.date, 61 | slug: this.getSlug(), 62 | body: this.renderBody(), 63 | media: this.media.map((media) => media.serialize()), 64 | hasBody: !!this.body, 65 | hasMedia: this.media.length > 0, 66 | 67 | ...this.customData 68 | }; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/core/entries/buildEntries.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import yaml from 'js-yaml'; 3 | import {readFileSync, writeFileSync} from 'fs'; 4 | import {sync as mkdirpSync} from 'mkdirp'; 5 | 6 | import Entry from './Entry'; 7 | import fetchMediaQueue from './media/fetchMediaQueue'; 8 | import {loadTweetCache, saveTweetCache} from './media/twitter/tweetCache'; 9 | import {loadPhotoCache, savePhotoCache} from './media/photos/photoCache'; 10 | 11 | function ensureCacheFoldersExist() { 12 | mkdirpSync('_cache/photos'); 13 | } 14 | 15 | export default async function buildEntries() { 16 | ensureCacheFoldersExist(); 17 | 18 | loadTweetCache(); 19 | loadPhotoCache(); 20 | 21 | const entriesYaml = readFileSync('_entries.yml', {encoding: 'utf8'}); 22 | 23 | const entryData = yaml.safeLoad(entriesYaml); 24 | 25 | // Create Entry models 26 | const entries = entryData.map((data) => new Entry(data)); 27 | 28 | // Hydrate media 29 | for (let entry of entries) { 30 | await entry.hydrateMedia(); 31 | } 32 | 33 | // Hydrate queued (batched) media 34 | const mediaQueue = _.flatten(entries.map((entry) => entry.getMediaQueue())); 35 | await fetchMediaQueue(mediaQueue); 36 | 37 | saveTweetCache(); 38 | savePhotoCache(); 39 | 40 | const serialized = entries.map((entry) => entry.serialize()); 41 | 42 | writeFileSync('_cache/entries.json', JSON.stringify(serialized), {encoding: 'utf8'}); 43 | } 44 | -------------------------------------------------------------------------------- /src/core/entries/media/Media.js: -------------------------------------------------------------------------------- 1 | export default class Media { 2 | constructor(yamlData) { 3 | // "meta" == poor name for user-defined data 4 | this.meta = yamlData; 5 | 6 | this.data = null; 7 | } 8 | 9 | async hydrate() { 10 | throw new Error('Not implemented: `hydrate` method on Media subclass'); 11 | } 12 | 13 | serialize() { 14 | return { 15 | type: this.type, 16 | data: this.data 17 | }; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/core/entries/media/fetchMediaQueue.js: -------------------------------------------------------------------------------- 1 | import Tweet from './twitter/Tweet'; 2 | import fetchTweets from './twitter/fetchTweets'; 3 | 4 | export default async function fetchMediaQueue(queue) { 5 | const tweets = queue.filter((item) => item instanceof Tweet); 6 | 7 | if (tweets.length > 0) { 8 | await fetchTweets(tweets); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/core/entries/media/getMedia.js: -------------------------------------------------------------------------------- 1 | import Tweet from './twitter/Tweet'; 2 | import Photo from './photos/Photo'; 3 | 4 | const tweetRe = /https:\/\/twitter.com\/.+\/status\/[0-9]+/; 5 | 6 | export default function getMedia(data) { 7 | if (typeof data === 'string') { 8 | if (tweetRe.test(data)) { 9 | return new Tweet(data); 10 | } 11 | 12 | } else { 13 | if (data.photo) { 14 | return new Photo(data); 15 | } 16 | } 17 | 18 | throw new Error(`Unrecognized media type: ${data}`); 19 | } 20 | -------------------------------------------------------------------------------- /src/core/entries/media/photos/Photo.js: -------------------------------------------------------------------------------- 1 | import getPhoto from './getPhoto'; 2 | import Media from '../Media'; 3 | 4 | export default class Photo extends Media { 5 | constructor(yamlData) { 6 | super(yamlData); 7 | 8 | this.type = 'photo'; 9 | } 10 | 11 | async hydrate() { 12 | const imgInfo = await getPhoto(this.meta.photo); 13 | 14 | this.data = { 15 | caption: this.meta.caption, 16 | ...imgInfo 17 | }; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/core/entries/media/photos/getConverter.js: -------------------------------------------------------------------------------- 1 | import {execSync} from 'child_process'; 2 | import gm from 'gm'; 3 | 4 | let converter; 5 | export default function getConverter() { 6 | if (converter !== undefined) { 7 | return converter; 8 | } 9 | 10 | try { 11 | execSync('gm version', {stdio: [null, null, null]}); 12 | converter = gm; 13 | return converter; 14 | 15 | } catch(err) { 16 | if (err.status !== 127) { 17 | throw err; 18 | } 19 | } 20 | 21 | try { 22 | execSync('convert -version', {stdio: [null, null, null]}); 23 | converter = gm.subClass({imageMagick: true}); 24 | return converter; 25 | 26 | } catch(err) { 27 | if (err.status !== 127) { 28 | throw err; 29 | } 30 | } 31 | 32 | throw new Error('Please install either ImageMagick or GraphicsMagick for photo resize support.'); 33 | } 34 | -------------------------------------------------------------------------------- /src/core/entries/media/photos/getPhoto.js: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto'; 2 | import fs from 'fs'; 3 | import path from 'path'; 4 | import getConverter from './getConverter'; 5 | 6 | import {getPhotoHash, addPhotoHash} from './photoCache'; 7 | 8 | import getSettings from '../../../../settings'; 9 | 10 | function getCachePath(filename) { 11 | return `./_cache/photos/${filename}`; 12 | } 13 | 14 | function resizeAndSave(srcPath, destPath, width, height) { 15 | return new Promise((resolve, reject) => { 16 | getConverter()(srcPath) 17 | .resize(width, height, '>') 18 | .write(destPath, (err) => { 19 | if (err) { 20 | reject(err); 21 | } else { 22 | resolve(); 23 | } 24 | }); 25 | }); 26 | } 27 | 28 | export default async function getPhoto(imgPath) { 29 | const settings = getSettings(); 30 | 31 | const srcPath = path.resolve(path.join(process.cwd(), settings.photos.dir), imgPath); 32 | 33 | let hash = getPhotoHash(srcPath); 34 | let cached = !!hash; 35 | 36 | if (!cached) { 37 | // Get hash of photo 38 | const sum = crypto.createHash('md5'); 39 | sum.update(fs.readFileSync(srcPath, {encoding: 'binary'})); 40 | hash = sum.digest('hex'); 41 | 42 | addPhotoHash(srcPath, hash); 43 | } 44 | 45 | // Build dictionary of sizes to urls: 46 | // { 47 | // sizes: { 48 | // large: "/assets/photos/hash:large.jpg", 49 | // small: "/assets/photos/hash:small.jpg" 50 | // } 51 | // } 52 | 53 | const sizes = {}; 54 | 55 | for (let sizeName in settings.photos.sizes) { 56 | const {w, h} = settings.photos.sizes[sizeName]; 57 | 58 | const filename = `${hash}:${sizeName}${path.extname(imgPath)}`; 59 | sizes[sizeName] = `/assets/photos/${filename}`; 60 | 61 | const cachePath = getCachePath(filename); 62 | 63 | // If a cached version doesn't exist, import it 64 | if (!cached) { 65 | await resizeAndSave(srcPath, cachePath, w, h); 66 | } 67 | } 68 | 69 | // Return img data 70 | return {sizes}; 71 | } 72 | -------------------------------------------------------------------------------- /src/core/entries/media/photos/photoCache.js: -------------------------------------------------------------------------------- 1 | import {readFileSync, writeFileSync} from 'fs'; 2 | import _ from 'lodash'; 3 | 4 | import getSettings from '../../../../settings'; 5 | 6 | const cachePath = `_cache/photos.json`; 7 | 8 | let cache = null; 9 | 10 | function getEmptyCache(sizes) { 11 | return { 12 | photos: {}, 13 | sizes 14 | }; 15 | } 16 | 17 | export function loadPhotoCache() { 18 | const sizes = getSettings().photos.sizes; 19 | 20 | let cacheJson; 21 | 22 | try { 23 | cacheJson = readFileSync(cachePath, {encoding: 'utf8'}); 24 | } catch(err) { 25 | cache = getEmptyCache(sizes); 26 | return; 27 | } 28 | 29 | cache = JSON.parse(cacheJson); 30 | 31 | // Bust photos cache if specified resize sizes have changed 32 | if (!_.isEqual(sizes, cache.sizes)) { 33 | console.log('*** Busted photo cache due to changed sizes'); 34 | cache = getEmptyCache(sizes); 35 | } 36 | } 37 | 38 | export function getPhotoHash(path) { 39 | if (cache === null) { 40 | throw new Error('Call `loadPhotoCache()` before attempting to get a photo'); 41 | } 42 | 43 | return cache.photos[path]; 44 | } 45 | 46 | export function addPhotoHash(path, hash) { 47 | cache.photos[path] = hash; 48 | } 49 | 50 | export function savePhotoCache() { 51 | const str = JSON.stringify(cache); 52 | writeFileSync(cachePath, str, {encoding: 'utf8'}); 53 | } 54 | -------------------------------------------------------------------------------- /src/core/entries/media/twitter/Tweet.js: -------------------------------------------------------------------------------- 1 | import Media from '../Media'; 2 | import serializeTweet from './serializeTweet'; 3 | import {getTweet, addTweet} from './tweetCache'; 4 | 5 | const TWEET_ID_RE = /https:\/\/twitter.com\/.+\/status\/([0-9]+)/; 6 | 7 | function getIdFromUrl(url) { 8 | return url.match(TWEET_ID_RE)[1]; 9 | } 10 | 11 | export default class Tweet extends Media { 12 | constructor(yamlData) { 13 | super(yamlData); 14 | 15 | this.type = 'tweet'; 16 | this.id = getIdFromUrl(this.meta); 17 | } 18 | 19 | async hydrate() { 20 | this.data = getTweet(this.id); 21 | } 22 | 23 | didFetch(data) { 24 | this.data = serializeTweet(data); 25 | addTweet(this.data); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/core/entries/media/twitter/fetchTweets.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import fetch from 'node-fetch'; 3 | 4 | import getToken from './getToken'; 5 | 6 | /* 7 | * Given a list of Tweet objects, fetches data for each tweet and calls 8 | * `tweet.didFetch(fetchedData)` to hydrate the object 9 | */ 10 | export default async function fetchTweets(tweets) { 11 | const token = await getToken(); 12 | 13 | // TODO: Split into chunks if urls.length > 100 14 | const idsParam = tweets.map((tweet) => tweet.id).join(','); 15 | 16 | const tweetsById = _.indexBy(tweets, 'id'); 17 | 18 | try { 19 | const resp = await fetch(`https://api.twitter.com/1.1/statuses/lookup.json?id=${idsParam}`, { 20 | method: 'POST', 21 | headers: { 22 | 'Authorization': `Bearer ${token}` 23 | } 24 | }); 25 | 26 | if (resp.status !== 200) { 27 | throw resp; 28 | } 29 | 30 | const fetched = await resp.json(); 31 | 32 | for (let tweetData of fetched) { 33 | tweetsById[tweetData.id_str].didFetch(tweetData); 34 | } 35 | 36 | } catch(err) { 37 | console.error('Error fetching tweets'); 38 | throw err; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/core/entries/media/twitter/getToken.js: -------------------------------------------------------------------------------- 1 | import {readFileSync} from 'fs'; 2 | import yaml from 'js-yaml'; 3 | import fetch from 'node-fetch'; 4 | 5 | export default async function getToken() { 6 | const privateYaml = readFileSync('_private.yml', {encoding: 'utf8'}); 7 | const privateSettings = yaml.safeLoad(privateYaml); 8 | 9 | const apiKey = privateSettings['twitter_api_key']; 10 | const apiSecret = privateSettings['twitter_api_secret']; 11 | 12 | const encodedKeySecret = encodeURIComponent(apiKey) + ':' + encodeURIComponent(apiSecret); 13 | const b64KeySecret = new Buffer(encodedKeySecret).toString('base64'); 14 | 15 | try { 16 | const resp = await fetch('https://api.twitter.com/oauth2/token', { 17 | method: 'POST', 18 | headers: { 19 | 'Authorization': `Basic ${b64KeySecret}`, 20 | 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' 21 | }, 22 | body: 'grant_type=client_credentials' 23 | }); 24 | 25 | if (resp.status !== 200) { 26 | throw resp; 27 | } 28 | 29 | const data = await resp.json(); 30 | 31 | return data.access_token; 32 | 33 | } catch(err) { 34 | throw err; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/core/entries/media/twitter/serializeTweet.js: -------------------------------------------------------------------------------- 1 | function serializeEntities(entities) { 2 | // TODO 3 | return entities; 4 | } 5 | 6 | function serializeExtendedEntities(entities) { 7 | // TODO 8 | return entities; 9 | } 10 | 11 | function serializeUser(user) { 12 | return { 13 | screen_name: user.screen_name, 14 | }; 15 | } 16 | 17 | export default function serializeTweet(tweet) { 18 | return { 19 | created_at: tweet.created_at, 20 | id_str: tweet.id_str, 21 | text: tweet.text, 22 | entities: serializeEntities(tweet.entities), 23 | extended_entities: serializeExtendedEntities(tweet.extended_entities), 24 | user: serializeUser(tweet.user), 25 | }; 26 | } 27 | -------------------------------------------------------------------------------- /src/core/entries/media/twitter/tweetCache.js: -------------------------------------------------------------------------------- 1 | import {readFileSync, writeFileSync} from 'fs'; 2 | 3 | const cachePath = `_cache/tweets.json`; 4 | 5 | let cache = null; 6 | 7 | export function loadTweetCache() { 8 | let tweetCacheJson; 9 | 10 | try { 11 | tweetCacheJson = readFileSync(cachePath, {encoding: 'utf8'}); 12 | } catch(err) { 13 | cache = {}; 14 | return; 15 | } 16 | 17 | cache = JSON.parse(tweetCacheJson); 18 | } 19 | 20 | export function getTweet(id) { 21 | if (cache === null) { 22 | throw new Error('Call `loadTweetCache()` before attempting to get a tweet'); 23 | } 24 | 25 | return cache[id]; 26 | } 27 | 28 | export function addTweet(tweet) { 29 | cache[tweet.id_str] = tweet; 30 | } 31 | 32 | export function saveTweetCache() { 33 | const str = JSON.stringify(cache); 34 | writeFileSync(cachePath, str, {encoding: 'utf8'}); 35 | } 36 | -------------------------------------------------------------------------------- /src/core/generateWebpackConfig.js: -------------------------------------------------------------------------------- 1 | import webpack from 'webpack'; 2 | import merge from 'webpack-merge'; 3 | import path from 'path'; 4 | import requireFromProject from '../util/requireFromProject'; 5 | 6 | export default function generateWebpackConfig(optimize) { 7 | const root = path.join(__dirname, '../..'); 8 | 9 | const customConfig = requireFromProject('./webpack.config.js')(optimize); 10 | 11 | const nodeEnv = optimize ? 'production' : 'development'; 12 | 13 | let defaultConfig = { 14 | resolve: { 15 | root: path.join(root, 'node_modules/') 16 | }, 17 | 18 | resolveLoader: { 19 | modulesDirectories: [ 20 | path.join(root, 'node_modules/'), 21 | path.join(process.cwd(), 'node_modules/') 22 | ] 23 | }, 24 | 25 | entry: { 26 | app: path.join(root, './app/entry.js'), 27 | vendor: [ 28 | 'babel-runtime/regenerator', 29 | 'react', 30 | 'react-router', 31 | 'react-redux', 32 | 'redux-actions', 33 | 'redux-promise-middleware', 34 | 'react-document-title', 35 | 'lodash', 36 | 'whatwg-fetch' 37 | ] 38 | }, 39 | 40 | output: { 41 | path: '_site/assets/', 42 | filename: '[name].bundle.js' 43 | }, 44 | 45 | plugins: [ 46 | new webpack.optimize.CommonsChunkPlugin('vendor', 'vendor.bundle.js'), 47 | 48 | new webpack.DefinePlugin({ 49 | // See app/projectRequire.js 50 | __PROJECT__: JSON.stringify(process.cwd()), 51 | 52 | 'process.env': { 53 | NODE_ENV: JSON.stringify(nodeEnv) 54 | } 55 | }), 56 | ], 57 | 58 | devtool: 'source-map', 59 | 60 | module: { 61 | loaders: [ 62 | { 63 | test: /\.js$/, 64 | exclude: /(node_modules\/)/, 65 | loader: 'babel-loader', 66 | query: { 67 | 'optional': ['es7.asyncFunctions', 'runtime'] 68 | } 69 | } 70 | ] 71 | } 72 | }; 73 | 74 | if (optimize) { 75 | defaultConfig = merge(defaultConfig, { 76 | plugins: [ 77 | new webpack.optimize.UglifyJsPlugin() 78 | ], 79 | devtool: null 80 | }); 81 | } 82 | 83 | return merge(defaultConfig, customConfig); 84 | } 85 | -------------------------------------------------------------------------------- /src/core/watch.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import chokidar from 'chokidar'; 3 | import pathIsInside from 'path-is-inside'; 4 | 5 | import errorWrap from '../util/errorWrap'; 6 | import build from './build'; 7 | 8 | // hmmmm 9 | let queuedPath = null; 10 | let building = false; 11 | 12 | async function rebuildOrQueue(event, changedPath) { 13 | queuedPath = changedPath; 14 | 15 | if (!building) { 16 | await rebuild(); 17 | } 18 | } 19 | 20 | async function rebuild() { 21 | const changedPath = queuedPath; 22 | 23 | building = true; 24 | queuedPath = null; 25 | 26 | console.log(`File "${changedPath}" changed...`); 27 | 28 | // Non-absolute paths are relative to cwd 29 | let absPath; 30 | if (!path.isAbsolute(changedPath)) { 31 | absPath = path.join(process.cwd(), changedPath); 32 | } else { 33 | absPath = changedPath; 34 | } 35 | 36 | const stylesDir = path.join(process.cwd(), 'styles'); 37 | 38 | if (changedPath === '_entries.yml') { 39 | await build(['entries', 'pages']); 40 | } else if (pathIsInside(absPath, stylesDir)) { 41 | await build(['webpack']); 42 | } else { 43 | await build(['pages', 'webpack']); 44 | } 45 | 46 | if (queuedPath) { 47 | rebuild(); 48 | } else { 49 | building = false; 50 | } 51 | } 52 | 53 | export default function watch() { 54 | chokidar.watch([ 55 | '_entries.yml', 56 | 'app/', 57 | 'styles/', 58 | 59 | // watch Peridot app folder 60 | path.join(__dirname, '../../app') 61 | ], { 62 | // ignore dotfiles 63 | ignored: /[\/\\]\./, 64 | 65 | // don't build on initial file add 66 | ignoreInitial: true 67 | }).on('all', (...args) => errorWrap(rebuildOrQueue, ...args)); 68 | } 69 | -------------------------------------------------------------------------------- /src/pagesBuilder/index.js: -------------------------------------------------------------------------------- 1 | import {readFileSync} from 'fs'; 2 | import {sync as mkdirpSync} from 'mkdirp'; 3 | 4 | import renderEntries from './render/renderEntries'; 5 | import renderList from './render/renderList'; 6 | import errorWrap from '../util/errorWrap'; 7 | 8 | function ensureBuildFoldersExist() { 9 | mkdirpSync('_site/entries'); 10 | } 11 | 12 | async function build(entries) { 13 | ensureBuildFoldersExist(); 14 | await renderEntries(entries); 15 | await renderList(entries); 16 | } 17 | 18 | export default async function main() { 19 | const entriesJson = readFileSync('_cache/entries.json', {encoding: 'utf8'}); 20 | const entries = JSON.parse(entriesJson); 21 | 22 | await errorWrap(build, entries); 23 | } 24 | -------------------------------------------------------------------------------- /src/pagesBuilder/injectGlobals.js: -------------------------------------------------------------------------------- 1 | // See app/projectRequire.js 2 | global.__PROJECT__ = process.cwd(); 3 | 4 | // TODO: I don't love the idea of stubbing out web APIs like this... 5 | // There might be an alternative through JSDOM? ask around 6 | global.window = {}; 7 | -------------------------------------------------------------------------------- /src/pagesBuilder/monkeyPatchModuleLoading.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Adapted from https://github.com/patrick-steele-idem/app-module-path-node 3 | * 4 | * This ludicrous monkey-patch allows project-local modules to import dependencies from 5 | * Peridot's node_modules/ folder. 6 | * 7 | * This ensures everything uses the same dependencies for React (required for various context 8 | * things), react-document-title (required for `DocumentTitle.rewind()` to work in page building), 9 | * and react-router (not required but at least keeps conflicting versions from existing). 10 | */ 11 | 12 | import path from 'path'; 13 | import {Module} from 'module'; 14 | 15 | const prevNodeModulePaths = Module._nodeModulePaths; 16 | 17 | const appModules = path.join(__dirname, '../../node_modules'); 18 | 19 | Module._nodeModulePaths = function(from) { 20 | const paths = prevNodeModulePaths.call(this, from); 21 | 22 | // If we're in project CWD 23 | if (from.indexOf(process.cwd()) !== -1) { 24 | // Use cli-app paths, first in order ensuring they take priority 25 | // (priority is important because React is probably installed in local project bc peer dep :<) 26 | return [appModules].concat(paths); 27 | } 28 | 29 | return paths; 30 | }; 31 | -------------------------------------------------------------------------------- /src/pagesBuilder/render/renderEntries.js: -------------------------------------------------------------------------------- 1 | import {writeFileSync} from 'fs'; 2 | import {sync as mkdirp} from 'mkdirp'; 3 | 4 | import renderPage from './renderPage'; 5 | 6 | export default async function renderEntries(entries) { 7 | // I can't figure out how to *sequentially* execute a series of promises with async/await, 8 | // so here's a regular ol' loop for now 9 | for (let entry of entries) { 10 | const url = `/entries/${entry.slug}/`; 11 | 12 | const hydratedEntries = {}; 13 | hydratedEntries[entry.slug] = true; 14 | 15 | const data = { 16 | entries: { 17 | entries: [entry], 18 | hydratedEntries: hydratedEntries, 19 | hydratedList: false 20 | } 21 | }; 22 | 23 | mkdirp(`_site/entries/${entry.slug}`); 24 | const htmlPath = `_site/entries/${entry.slug}/index.html`; 25 | const jsonPath = `_site/entries/${entry.slug}/data.json`; 26 | 27 | await renderPage({ 28 | data: data, 29 | url: url, 30 | path: htmlPath 31 | }); 32 | 33 | const entryJson = JSON.stringify(entry); 34 | writeFileSync(jsonPath, entryJson, {encoding: 'utf8'}); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/pagesBuilder/render/renderList.js: -------------------------------------------------------------------------------- 1 | import {writeFileSync} from 'fs'; 2 | 3 | import renderPage from './renderPage'; 4 | 5 | 6 | export default async function renderList(entries) { 7 | const shortEntries = entries.map((entry) => { 8 | return { 9 | title: entry.title, 10 | location: entry.location, 11 | date: entry.date, 12 | slug: entry.slug, 13 | hasBody: !!entry.body, 14 | hasMedia: entry.media.length > 0 15 | }; 16 | }); 17 | 18 | const data = { 19 | entries: { 20 | entries: shortEntries, 21 | hydratedEntries: {}, 22 | hydratedList: true 23 | } 24 | }; 25 | 26 | await renderPage({ 27 | data: data, 28 | url: '/', 29 | path: '_site/index.html' 30 | }); 31 | 32 | const entryJson = JSON.stringify(shortEntries); 33 | writeFileSync('_site/entries.json', entryJson, {encoding: 'utf8'}); 34 | } 35 | -------------------------------------------------------------------------------- /src/pagesBuilder/render/renderPage.js: -------------------------------------------------------------------------------- 1 | import {writeFileSync} from 'fs'; 2 | 3 | import React from 'react'; 4 | import DocumentTitle from 'react-document-title'; 5 | 6 | import renderRoute from './renderRoute'; 7 | 8 | import requireFromProject from '../../util/requireFromProject'; 9 | const Page = requireFromProject('app/components/layouts/Page'); 10 | 11 | export default async function renderPage({data, url, path}) { 12 | const innerHTML = await renderRoute(url, data); 13 | 14 | // Rewinds from previous render (slightly magic!) 15 | const title = DocumentTitle.rewind(); 16 | 17 | const json = JSON.stringify(data); 18 | 19 | const listDataEmbed = { 20 | __html: `window.__data__ = ${json};` 21 | }; 22 | 23 | const html = React.renderToStaticMarkup( 24 | 25 |
26 | 27 |