├── .circleci └── config.yml ├── .gitignore ├── LICENSE ├── README.md ├── package.json ├── src ├── components │ ├── flush.js │ ├── list.js │ ├── post-body.js │ ├── post.js │ └── stub.js ├── generate-blog-index.js └── index.js ├── test └── index.test.js └── yarn.lock /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | defaults: &defaults 4 | working_directory: ~/next-mdx-blog 5 | docker: 6 | - image: circleci/node:latest-browsers 7 | 8 | jobs: 9 | install: 10 | <<: *defaults 11 | steps: 12 | - checkout 13 | - restore_cache: 14 | keys: 15 | # Find a cache corresponding to this specific package.json checksum 16 | # when this file is changed, this key will fail 17 | - next-mdx-blog-{{ .Branch }}-{{ checksum "yarn.lock" }}-{{ checksum ".circleci/config.yml" }} 18 | - next-mdx-blog-{{ .Branch }}-{{ checksum "yarn.lock" }} 19 | - next-mdx-blog-{{ .Branch }} 20 | # Find the most recent cache used from any branch 21 | - next-mdx-blog-master 22 | - next-mdx-blog- 23 | - run: 24 | name: Install Dependencies 25 | command: yarn install --frozen-lockfile 26 | - save_cache: 27 | key: next-mdx-blog-{{ .Branch }}-{{ checksum "yarn.lock" }}-{{ checksum ".circleci/config.yml" }} 28 | paths: 29 | - node_modules 30 | - ~/.cache/yarn 31 | - persist_to_workspace: 32 | root: . 33 | paths: 34 | - . 35 | 36 | lint: 37 | <<: *defaults 38 | steps: 39 | - attach_workspace: 40 | at: ~/next-mdx-blog 41 | - run: 42 | name: Lint 43 | command: yarn lint 44 | 45 | test: 46 | <<: *defaults 47 | steps: 48 | - attach_workspace: 49 | at: ~/next-mdx-blog 50 | - run: 51 | name: Test 52 | command: yarn test 53 | 54 | build: 55 | <<: *defaults 56 | steps: 57 | - attach_workspace: 58 | at: ~/next-mdx-blog 59 | - run: 60 | name: Build 61 | command: yarn build 62 | - persist_to_workspace: 63 | root: . 64 | paths: 65 | - . 66 | 67 | release: 68 | <<: *defaults 69 | steps: 70 | - attach_workspace: 71 | at: ~/next-mdx-blog 72 | 73 | - run: 74 | name: 'Make sure we can commit to github' 75 | command: | 76 | mkdir ~/.ssh/ && echo -e "Host github.com\n\tStrictHostKeyChecking no\n" > ~/.ssh/config 77 | 78 | - run: echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" >> ~/next-mdx-blog/.npmrc 79 | - run: npm whoami 80 | 81 | - run: 82 | name: Release 83 | command: npm run release 84 | 85 | workflows: 86 | version: 2 87 | build_and_test: 88 | jobs: 89 | - install: 90 | filters: 91 | tags: 92 | only: /.*/ 93 | 94 | - lint: 95 | requires: 96 | - install 97 | filters: 98 | tags: 99 | only: /.*/ 100 | 101 | - test: 102 | requires: 103 | - install 104 | filters: 105 | tags: 106 | only: /.*/ 107 | 108 | - build: 109 | requires: 110 | - install 111 | filters: 112 | tags: 113 | only: /.*/ 114 | 115 | - release: 116 | requires: 117 | - test 118 | - lint 119 | - build 120 | filters: 121 | branches: 122 | only: 123 | - master 124 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | coverage 3 | dist 4 | yarn-error.log 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 Andrew Lisowski 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 14 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 15 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 16 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, 17 | DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 18 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 19 | OR OTHER DEALINGS IN THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 4 |

next-mdx-blog

5 |

Easy blog for next.js

6 |
109 | ``` 110 | 111 | #### Usage with next.js 112 | 113 | To use the components with next.js you have to flush the styles. This is a bug in styled-jsx component package + next.js. To remedy this manually flush the styles: 114 | 115 | ```js 116 | import React from 'react'; 117 | import Document, { Head, Main, NextScript } from 'next/document'; 118 | import flush from 'styled-jsx/server'; 119 | import flushBlog from 'next-mdx-blog/dist/components/flush'; 120 | 121 | export default class MyDocument extends Document { 122 | static getInitialProps({ renderPage }) { 123 | const { html, head, errorHtml, chunks } = renderPage(); 124 | return { html, head, errorHtml, chunks, styles: [flush(), flushBlog()] }; 125 | } 126 | 127 | render() {} 128 | } 129 | ``` 130 | 131 | #### List 132 | 133 | A list of blog posts. Each post displays a small preview of it's content. You must dynamically require the blog posts to get the previews working. This component should be used to display the blog index. 134 | 135 | `pages/blog.js`: 136 | 137 | ```js 138 | import React from 'react'; 139 | import Head from 'next/head'; 140 | import BlogIndex from 'next-mdx-blog/dist/components/list'; 141 | 142 | import postsData from '../posts'; 143 | 144 | // Dynamically import the blog posts 145 | postsData.forEach(post => { 146 | post.file = import('../pages' + post.filePath.replace('pages', '')); 147 | }); 148 | 149 | const blogPage = ({ posts = postsData }) => ( 150 |
151 | 152 | Blog Posts 153 | 154 | 155 | 156 |
157 | ); 158 | 159 | // Before page loads await the dynamic components. prevents blog preview page flash. 160 | blogPage.getInitialProps = async () => { 161 | await Promise.all( 162 | postsData.map(async post => { 163 | post.BlogPost = (await post.file).default; 164 | 165 | return post; 166 | }) 167 | ); 168 | 169 | return { posts: [...postsData] }; 170 | }; 171 | 172 | export default blogPage; 173 | ``` 174 | 175 | ##### List Props 176 | 177 | ###### posts 178 | 179 | The post index generated by the next plugin. 180 | 181 | ###### perPage 182 | 183 | How many posts to display per page. 184 | 185 | ###### className 186 | 187 | Classname for the root div. 188 | 189 | ###### stubClassName 190 | 191 | Classname for the post stubs. 192 | 193 | ###### foldHeight 194 | 195 | How much of the post should be displayed before the fold. 196 | 197 | #### Post 198 | 199 | A full blog post. To get your blog content to render inside the blog posts component your must either 200 | 201 | 1. Modify `_app.js` to render blog content inside appropriate wrapper 202 | 203 | ```js 204 | import React from 'react'; 205 | import App, { Container } from 'next/app'; 206 | import Layout from '../components/layout'; 207 | import BlogPost from 'next-mdx-blog/dist/components/post'; 208 | import posts from '../posts'; 209 | 210 | // Override the App class to put layout component around the page contents 211 | // https://github.com/zeit/next.js#custom-app 212 | 213 | export default class MyApp extends App { 214 | render() { 215 | const { Component, pageProps } = this.props; 216 | const { pathname } = this.props.router; 217 | 218 | return ( 219 | 220 | 221 | {pathname.includes('blog/') ? ( 222 | post.urlPath === pathname)} 224 | className="content" 225 | > 226 | 227 | 228 | ) : ( 229 | 230 | )} 231 | 232 | 233 | ); 234 | } 235 | } 236 | ``` 237 | 238 | 2. Wrap blog content inside each `mdx` file. This is more work but you can customize each blog post. 239 | 240 | ```mdx 241 | export const meta = { 242 | publishDate: '2018-05-10T12:00:00Z', 243 | title: 'First Post', 244 | } 245 | 246 | import Post from 'next-mdx-blog/dist/components/post' 247 | 248 | 249 | # Section 1.10.32 of "de Finibus Bonorum et Malorum", written by Cicero in 45 BC 250 | 251 | ``` 252 | 253 | ##### Post Props 254 | 255 | ###### children 256 | 257 | Post body. 258 | 259 | ###### className 260 | 261 | Classname to wrap the post in. 262 | 263 | ###### post 264 | 265 | The post meta data to display. 266 | 267 | ##### \_app.js - Asset Prefix 268 | 269 | If you are prefixing your URLS you will need to identify posts by prefixing the pathname. 270 | 271 | ```js 272 | const prefixUrl = (p) => path.join(assetPrefix, p) 273 | 274 | post.urlPath === prefixUrl(pathname))}> 275 | 276 | 277 | ``` 278 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.1", 3 | "name": "next-mdx-blog", 4 | "description": "next-mdx-blog", 5 | "main": "dist/index.js", 6 | "source": "src/index.js", 7 | "author": { 8 | "name": "Andrew Lisowski", 9 | "email": "lisowski54@gmail.com" 10 | }, 11 | "files": [ 12 | "dist/" 13 | ], 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/hipstersmoothie/next-mdx-blog" 17 | }, 18 | "scripts": { 19 | "build": "babel src -d dist --ignore '**/*.test.js'", 20 | "test": "jest", 21 | "lint": "xo", 22 | "prerelease": "npm run build", 23 | "release": "github-semantic-version --bump --changelog --push --publish" 24 | }, 25 | "devDependencies": { 26 | "@babel/cli": "^7.1.2", 27 | "@babel/core": "^7.1.2", 28 | "@babel/plugin-proposal-class-properties": "^7.1.0", 29 | "@babel/plugin-proposal-object-rest-spread": "^7.0.0", 30 | "@babel/plugin-syntax-dynamic-import": "^7.0.0", 31 | "@babel/plugin-transform-modules-commonjs": "^7.1.0", 32 | "@babel/plugin-transform-regenerator": "^7.0.0", 33 | "@babel/plugin-transform-runtime": "^7.1.0", 34 | "@babel/preset-env": "^7.1.0", 35 | "@babel/preset-react": "^7.0.0", 36 | "@babel/runtime": "^7.1.2", 37 | "babel-core": "^7.0.0-bridge.0", 38 | "babel-eslint": "^10.0.1", 39 | "babel-jest": "^23.6.0", 40 | "eslint": "^5.7.0", 41 | "eslint-config-prettier": "^3.1.0", 42 | "eslint-config-xo-react": "^0.17.0", 43 | "eslint-plugin-react": "^7.11.1", 44 | "github-semantic-version": "^7.6.0", 45 | "husky": "^1.1.2", 46 | "jest": "^23.6.0", 47 | "lint-staged": "^7.3.0", 48 | "prettier": "^1.14.3", 49 | "xo": "^0.23.0" 50 | }, 51 | "peerDepedencies": { 52 | "styled-jsx": "3.1.0" 53 | }, 54 | "dependencies": { 55 | "@mdx-js/mdx": "^0.15.5", 56 | "bulma-pagination-react": "^0.0.3", 57 | "dayjs": "^1.7.7", 58 | "glob": "^7.1.3", 59 | "next": "^7.0.2", 60 | "prop-types": "^15.6.2", 61 | "react": "^16.5.2", 62 | "react-dom": "^16.5.2", 63 | "rss": "^1.2.2", 64 | "styled-jsx": "^3.1.0" 65 | }, 66 | "prettier": { 67 | "singleQuote": true 68 | }, 69 | "husky": { 70 | "hooks": { 71 | "pre-commit": "lint-staged" 72 | } 73 | }, 74 | "lint-staged": { 75 | "*.{js,json,css,md}": [ 76 | "prettier --write", 77 | "git add" 78 | ] 79 | }, 80 | "jest": { 81 | "collectCoverage": true, 82 | "coverageDirectory": "./coverage", 83 | "coverageReporters": [ 84 | "json", 85 | "lcov", 86 | "text", 87 | "html" 88 | ], 89 | "collectCoverageFrom": [ 90 | "**/*.{js,jsx}", 91 | "!**/node_modules/**", 92 | "!**/vendor/**" 93 | ], 94 | "transform": { 95 | "^.+\\.jsx?$": "babel-jest" 96 | } 97 | }, 98 | "xo": { 99 | "parser": "babel-eslint", 100 | "env": [ 101 | "dom", 102 | "jest" 103 | ], 104 | "extends": [ 105 | "prettier", 106 | "xo-react/space" 107 | ], 108 | "rules": { 109 | "react/jsx-tag-spacing": "always" 110 | } 111 | }, 112 | "babel": { 113 | "presets": [ 114 | "@babel/env", 115 | "next/babel" 116 | ] 117 | }, 118 | "license": "MIT", 119 | "gsv": { 120 | "startVersion": "0.0.0", 121 | "majorLabel": "Version: Major", 122 | "minorLabel": "Version: Minor", 123 | "patchLabel": "Version: Patch", 124 | "internalLabel": "internal" 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/components/flush.js: -------------------------------------------------------------------------------- 1 | import flushPost from 'styled-jsx/server'; 2 | 3 | export default flushPost; 4 | -------------------------------------------------------------------------------- /src/components/list.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Router, { withRouter } from 'next/router'; 3 | import Pagination from 'bulma-pagination-react'; 4 | 5 | import BlogStub from './stub'; 6 | 7 | const POSTS_PER_PAGE = 10; 8 | 9 | const StubList = withRouter( 10 | ({ 11 | posts, 12 | perPage = POSTS_PER_PAGE, 13 | router, 14 | className, 15 | stubClassName, 16 | foldHeight 17 | }) => { 18 | const pages = Math.ceil(posts.length / perPage); 19 | const currentPage = Number(router.query.page || 1); 20 | 21 | return ( 22 |
23 |
24 | {posts 25 | .slice((currentPage - 1) * perPage, currentPage * perPage) 26 | .map((post, i) => ( 27 | 34 | ))} 35 |
36 | 37 | Router.push(`/blog?page=${page}`)} 42 | /> 43 | 44 | 54 |
55 | ); 56 | } 57 | ); 58 | 59 | export default StubList; 60 | -------------------------------------------------------------------------------- /src/components/post-body.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import dayjs from 'dayjs'; 4 | 5 | const PostBody = ({ children, post, title, className }) => ( 6 |
7 |
8 |
9 |
10 |
19 |

{title || post.title}

20 |

21 | 27 | {post.author} 28 | 29 | on {dayjs(post.publishDate).format('MMMM D, YYYY')} 30 |

31 |
32 |
33 |
{children}
34 |
35 | 36 | 80 |
81 | ); 82 | 83 | PostBody.propTypes = { 84 | className: PropTypes.string, 85 | title: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired, 86 | children: PropTypes.node.isRequired, 87 | post: PropTypes.object.isRequired 88 | }; 89 | 90 | PostBody.defaultProps = { 91 | className: '' 92 | }; 93 | 94 | export default PostBody; 95 | -------------------------------------------------------------------------------- /src/components/post.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import Head from 'next/head'; 4 | 5 | import PostBody from './post-body'; 6 | 7 | const BlogPost = ({ children, post, className }) => ( 8 | 9 | 10 | {post.title} 11 | 12 | 13 | {children} 14 | 15 | 22 | 23 | ); 24 | 25 | BlogPost.propTypes = { 26 | className: PropTypes.string, 27 | children: PropTypes.node.isRequired, 28 | post: PropTypes.object.isRequired 29 | }; 30 | 31 | BlogPost.defaultProps = { 32 | className: '' 33 | }; 34 | 35 | export default BlogPost; 36 | -------------------------------------------------------------------------------- /src/components/stub.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import Link from 'next/link'; 4 | 5 | import PostBody from './post-body'; 6 | 7 | class BlogStub extends Component { 8 | static propTypes = { 9 | className: PropTypes.string, 10 | foldHeight: PropTypes.number, 11 | post: PropTypes.object.isRequired 12 | }; 13 | 14 | static defaultProps = { 15 | className: '', 16 | foldHeight: 200 17 | }; 18 | 19 | state = { 20 | BlogPost: null, 21 | fade: false 22 | }; 23 | 24 | async componentDidMount() { 25 | if (!this.props.post.BlogPost) { 26 | const file = await this.props.post.file; 27 | 28 | this.setState({ 29 | BlogPost: file.default 30 | }); 31 | } 32 | 33 | if ( 34 | this.container.offsetHeight > this.props.foldHeight && 35 | !this.state.fade 36 | ) { 37 | // eslint-disable-next-line react/no-did-update-set-state 38 | this.setState({ fade: true }); 39 | } 40 | } 41 | 42 | render() { 43 | const { post } = this.props; 44 | const BlogPost = post.BlogPost || this.state.BlogPost; 45 | 46 | return ( 47 | 52 | {post.title} 53 | 54 | } 55 | > 56 |
{ 58 | this.container = el; 59 | }} 60 | className={`preview ${this.props.className}`} 61 | > 62 | {BlogPost && } 63 | {this.state.fade && ( 64 |
65 |
66 | 67 | Read More 68 | 69 |
70 | )} 71 |
72 | 102 | 103 | ); 104 | } 105 | } 106 | 107 | export default BlogStub; 108 | -------------------------------------------------------------------------------- /src/generate-blog-index.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const Module = require('module'); 4 | const { promisify } = require('util'); 5 | 6 | const RSS = require('rss'); 7 | const g = require('glob'); 8 | const mdx = require('@mdx-js/mdx'); 9 | const babel = require('@babel/core'); 10 | 11 | const glob = promisify(g); 12 | 13 | function requireFromStringSync(src, filename) { 14 | const m = new Module(); 15 | m._compile(src, filename); 16 | return m.exports; 17 | } 18 | 19 | function requireMDXSync(mdxSrc, filename) { 20 | const jsx = mdx.sync(mdxSrc); 21 | const transformed = babel.transformSync(jsx, { 22 | babelrc: false, 23 | presets: ['@babel/preset-react'], 24 | plugins: [ 25 | '@babel/plugin-transform-modules-commonjs', 26 | '@babel/plugin-proposal-object-rest-spread' 27 | ] 28 | }); 29 | return requireFromStringSync(transformed.code, filename); 30 | } 31 | 32 | function requireMDXFileSync(path) { 33 | const mdxSrc = fs.readFileSync(path, { encoding: 'utf-8' }); 34 | return requireMDXSync(mdxSrc, path); 35 | } 36 | 37 | function readPostMetadata(filePath) { 38 | const mod = requireMDXFileSync(filePath); 39 | const { meta } = mod; 40 | 41 | return { 42 | filePath, 43 | urlPath: filePath 44 | .replace(/\\/, '/') 45 | .replace(/^pages/, '') 46 | .replace(/\.mdx?$/, ''), 47 | title: meta.title || path.basename(filePath), 48 | publishDate: new Date(meta.publishDate) 49 | }; 50 | } 51 | 52 | function generateRSS(posts) { 53 | const siteUrl = 'https://hipstersmoothie.com'; 54 | const feed = new RSS({ 55 | title: "Andrew Lisowski's blog", 56 | // eslint-disable-next-line camelcase 57 | site_url: siteUrl 58 | }); 59 | 60 | posts.forEach(post => { 61 | feed.item({ 62 | title: post.title, 63 | guid: post.urlPath, 64 | url: siteUrl + post.urlPath, 65 | date: post.publishDate 66 | }); 67 | }); 68 | 69 | return feed.xml({ indent: true }); 70 | } 71 | 72 | module.exports = async function(options) { 73 | const postPaths = await glob('pages/**/*.mdx'); 74 | const now = new Date(); 75 | 76 | const posts = postPaths 77 | .map(readPostMetadata) 78 | .map(post => { 79 | post.author = post.author || options.author; 80 | post.authorLink = post.authorLink || options.authorLink; 81 | post.avatar = post.avatar || options.avatar; 82 | post.urlPath = path.join(options.assetPrefix || '/', post.urlPath); 83 | 84 | return post; 85 | }) 86 | .filter(post => post.publishDate <= now) 87 | .sort((a, b) => b.publishDate - a.publishDate); 88 | 89 | const postsJSON = JSON.stringify(posts, null, 2); 90 | const exportPath = 'posts.js'; 91 | 92 | fs.writeFileSync( 93 | exportPath, 94 | '// automatically generated by build_post_index.js\n' + 95 | `export default ${postsJSON}\n` 96 | ); 97 | 98 | console.info(`Saved ${posts.length} posts in ${exportPath}`); 99 | 100 | const rssPath = 'static/rss-feed.xml'; 101 | const rssXML = generateRSS(posts); 102 | 103 | fs.writeFileSync(rssPath, rssXML); 104 | 105 | console.info(`Saved RSS feed to ${rssPath}`); 106 | }; 107 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const generateBlog = require('./generate-blog-index'); 2 | 3 | class BlogIndexPlugin { 4 | constructor(options) { 5 | this.options = options; 6 | } 7 | 8 | getChangedFiles(compiler) { 9 | const { watchFileSystem } = compiler; 10 | const watcher = watchFileSystem.watcher || watchFileSystem.wfs.watcher; 11 | 12 | return Object.keys(watcher.mtimes); 13 | } 14 | 15 | apply(compiler) { 16 | // Set up blog index at start 17 | compiler.hooks.environment.tap('MyPlugin', () => { 18 | generateBlog(this.options); 19 | }); 20 | 21 | // Re generate blog index when MDX files change 22 | compiler.hooks.watchRun.tap('MyPlugin', () => { 23 | const changedFile = this.getChangedFiles(compiler); 24 | 25 | if (changedFile.find(file => file.includes('.mdx'))) { 26 | generateBlog(this.options); 27 | } 28 | }); 29 | } 30 | } 31 | 32 | module.exports = (nextConfig = {}) => { 33 | return Object.assign({}, nextConfig, { 34 | webpack(config, options) { 35 | if (!options.defaultLoaders) { 36 | throw new Error( 37 | 'This plugin is not compatible with Next.js versions below 5.0.0 https://err.sh/next-plugins/upgrade' 38 | ); 39 | } 40 | 41 | config.plugins.push(new BlogIndexPlugin(nextConfig)); 42 | 43 | if (typeof nextConfig.webpack === 'function') { 44 | return nextConfig.webpack(config, options); 45 | } 46 | 47 | return config; 48 | } 49 | }); 50 | }; 51 | -------------------------------------------------------------------------------- /test/index.test.js: -------------------------------------------------------------------------------- 1 | test('test name', () => {}); 2 | --------------------------------------------------------------------------------