├── src ├── cms │ ├── cms.css │ ├── components │ │ ├── FileSystemBackend │ │ │ ├── index.js │ │ │ ├── lib │ │ │ │ ├── APIError.js │ │ │ │ └── pathHelper.js │ │ │ ├── AuthenticationPage.css │ │ │ ├── AuthenticationPage.js │ │ │ ├── implementation.js │ │ │ └── API.js │ │ └── EditorYoutube │ │ │ └── index.js │ ├── cms.js │ └── file-system-api-plugin │ │ ├── README.md │ │ ├── fs-api.js │ │ └── fs-express-api.js ├── pages │ ├── 404.js │ └── index.js ├── templates │ └── blog-post.js └── components │ ├── header.js │ ├── layout.js │ └── layout.css ├── static ├── assets │ ├── uploads │ │ └── chaplin.jpg │ └── media │ │ └── netlify_logo.svg └── admin │ └── config.yml ├── .gitignore ├── README.md ├── gatsby-config.js ├── package.json ├── content └── articles │ ├── post-2.md │ └── test.md └── gatsby-node.js /src/cms/cms.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /static/assets/uploads/chaplin.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bjrn/gatsby-with-netlify-cms/HEAD/static/assets/uploads/chaplin.jpg -------------------------------------------------------------------------------- /src/cms/components/FileSystemBackend/index.js: -------------------------------------------------------------------------------- 1 | import FileSystemBackend from './implementation'; 2 | 3 | /** 4 | * Add extension hooks to global scope. 5 | */ 6 | if (typeof window !== 'undefined') { 7 | window.FileSystemBackend = FileSystemBackend; 8 | } 9 | 10 | export default FileSystemBackend; -------------------------------------------------------------------------------- /src/pages/404.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Layout from '../components/layout' 3 | 4 | const NotFoundPage = () => ( 5 | 6 |

NOT FOUND

7 |

You just hit a route that doesn't exist... the sadness.

8 |
9 | ) 10 | 11 | export default NotFoundPage 12 | -------------------------------------------------------------------------------- /src/cms/components/FileSystemBackend/lib/APIError.js: -------------------------------------------------------------------------------- 1 | export const API_ERROR = 'API_ERROR'; 2 | 3 | export default class APIError extends Error { 4 | constructor(message, status, api) { 5 | super(message); 6 | this.message = message; 7 | this.status = status; 8 | this.api = api; 9 | this.name = API_ERROR; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Project dependencies 2 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 3 | node_modules 4 | .env 5 | .env.* 6 | 7 | # Gatsby Cache + Build directory 8 | .cache/ 9 | public/ 10 | 11 | .DS_Store 12 | yarn-error.log 13 | 14 | # Jest reports 15 | coverage 16 | 17 | # IDE 18 | .idea/ 19 | .history/ -------------------------------------------------------------------------------- /src/cms/components/EditorYoutube/index.js: -------------------------------------------------------------------------------- 1 | export default { 2 | id: 'youtube', 3 | label: 'Youtube', 4 | fields: [{ name: 'id', label: 'Youtube Video ID' }], 5 | pattern: /^{{<\s?youtube (\S+)\s?>}}/, 6 | fromBlock: match => ({ 7 | id: match[1], 8 | }), 9 | toBlock: obj => `{{< youtube ${obj.id} >}}`, 10 | toPreview: obj => `Youtube Video`, 11 | } 12 | -------------------------------------------------------------------------------- /src/cms/components/FileSystemBackend/AuthenticationPage.css: -------------------------------------------------------------------------------- 1 | .fs-auth-page-root { 2 | display: flex; 3 | align-items: center; 4 | justify-content: center; 5 | height: 100vh; 6 | } 7 | 8 | .fs-auth-page-card { 9 | width: 400px; 10 | padding: 10px; 11 | } 12 | 13 | .fs-auth-page-card img { 14 | width: 480px; 15 | } 16 | 17 | .fs-auth-page-errorMsg { 18 | color: #dd0000; 19 | } 20 | 21 | .fs-auth-page-message { 22 | font-size: 1.1em; 23 | margin: 20px 10px; 24 | } 25 | 26 | .fs-auth-page-button { 27 | padding: .25em 1em; 28 | height: auto; 29 | } 30 | -------------------------------------------------------------------------------- /src/templates/blog-post.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { graphql } from 'gatsby'; 3 | import Layout from '../components/layout'; 4 | 5 | export default ({ data }) => { 6 | const post = data.markdownRemark; 7 | return ( 8 | 9 |
10 |

{post.frontmatter.title}

11 | 12 |
13 |
14 | 15 | ); 16 | }; 17 | 18 | export const query = graphql` 19 | query($slug: String!) { 20 | markdownRemark(fields: { slug: { eq: $slug } }) { 21 | html 22 | frontmatter { 23 | title 24 | } 25 | } 26 | } 27 | `; 28 | -------------------------------------------------------------------------------- /src/components/header.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'gatsby'; 3 | 4 | const Header = ({ siteTitle }) => ( 5 |
11 |
18 |

19 | 26 | {siteTitle} 27 | 28 |

29 |
30 |
31 | ); 32 | 33 | export default Header; 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Gatsby site using NetlifyCMS with local file-system api in dev mode 2 | 3 | Check out the project from github: 4 | 5 | ``` 6 | git clone https://github.com/bjrn/gatsby-with-netlify-cms.git 7 | cd gatsby-with-netlify-cms 8 | yarn 9 | ``` 10 | 11 | Start develop mode: 12 | 13 | ``` 14 | yarn develop 15 | ``` 16 | 17 | After startup, there's a local website running on localhost:8000. 18 | 19 | The editor is located under . 20 | In development mode, editing documents is file-based, 21 | ie. saving a document will update the document on your harddrive, unlike editing on the built site, where document changes result in Pull Requests that can be merged via the publish button in the built-in workflow (if `editorial_mode` is enabled). 22 | 23 | Local editing is great for making changes in many documents at once, or for migrating content from other sites. 24 | 25 | ## fs-api functionality by @talves 26 | 27 | see repo for a create-react-app version: 28 | 29 | -------------------------------------------------------------------------------- /gatsby-config.js: -------------------------------------------------------------------------------- 1 | // add as a dev-proxy 2 | const fileSystemAPI = require('./src/cms/file-system-api-plugin/fs-express-api'); 3 | 4 | module.exports = { 5 | siteMetadata: { 6 | title: 'Gatsby NetlifyCMS (fs-api)', 7 | }, 8 | plugins: [ 9 | { 10 | resolve: `gatsby-source-filesystem`, 11 | options: { 12 | name: `articles`, 13 | path: `${__dirname}/content/articles`, 14 | }, 15 | }, 16 | 'gatsby-plugin-react-helmet', 17 | 'gatsby-transformer-remark', 18 | { 19 | resolve: `gatsby-plugin-netlify-cms`, 20 | options: { 21 | modulePath: `${__dirname}/src/cms/cms.js`, // default: undefined 22 | stylesPath: `${__dirname}/src/cms/cms.css`, // default: undefined 23 | enableIdentityWidget: false, // default: true 24 | publicPath: 'admin', 25 | htmlTitle: 'Content Manager', 26 | manualInit: true, 27 | }, 28 | }, 29 | ], 30 | // add the file-system api as an api proxy: 31 | // https://next.gatsbyjs.org/docs/api-proxy/#advanced-proxying 32 | developMiddleware: fileSystemAPI, 33 | }; 34 | -------------------------------------------------------------------------------- /src/components/layout.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import Helmet from 'react-helmet'; 4 | import { StaticQuery, graphql } from 'gatsby'; 5 | 6 | import Header from './header'; 7 | import './layout.css'; 8 | 9 | const Layout = ({ children, data }) => ( 10 | { 21 | const { title } = data.site.siteMetadata; 22 | return ( 23 | <> 24 | 31 |
32 | {children} 33 | 34 | ); 35 | }} 36 | /> 37 | ); 38 | 39 | Layout.propTypes = { 40 | children: PropTypes.node.isRequired, 41 | }; 42 | 43 | export default Layout; 44 | -------------------------------------------------------------------------------- /src/pages/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link, graphql } from 'gatsby'; 3 | 4 | import Layout from '../components/layout'; 5 | 6 | export default ({ data }) => { 7 | return ( 8 | 9 |

{data.posts.totalCount} Posts

10 | {data.posts.edges.map(({ node }) => ( 11 |
12 |

13 | {node.frontmatter.title} 14 | — {node.frontmatter.date} 15 |

16 |

{node.excerpt}

17 |
18 | ))} 19 |
20 | ); 21 | }; 22 | 23 | export const query = graphql` 24 | query { 25 | posts: allMarkdownRemark( 26 | sort: { fields: [frontmatter___date], order: DESC } 27 | ) { 28 | totalCount 29 | edges { 30 | node { 31 | id 32 | frontmatter { 33 | title 34 | date(formatString: "DD MMMM, YYYY") 35 | } 36 | fields { 37 | slug 38 | } 39 | excerpt 40 | } 41 | } 42 | } 43 | } 44 | `; 45 | -------------------------------------------------------------------------------- /src/cms/components/FileSystemBackend/AuthenticationPage.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types' 2 | import React from 'react' 3 | // eslint-disable-next-line 4 | import styles from './AuthenticationPage.css' 5 | 6 | export default class AuthenticationPage extends React.Component { 7 | static propTypes = { 8 | onLogin: PropTypes.func.isRequired, 9 | inProgress: PropTypes.bool, 10 | } 11 | 12 | state = { email: '' } 13 | 14 | handleLogin = e => { 15 | e.preventDefault() 16 | this.props.onLogin(this.state) 17 | } 18 | 19 | handleEmailChange = value => { 20 | this.setState({ email: value }) 21 | } 22 | 23 | render() { 24 | const { inProgress } = this.props 25 | console.log('AUthenticationPAGE') 26 | return ( 27 |
28 |
29 | 36 |
37 |
38 | ) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gatsby-with-netlify-cms", 3 | "description": "Gatsby site using NetlifyCMS with local file-system api in dev mode", 4 | "version": "1.0.0", 5 | "scripts": { 6 | "build": "gatsby build", 7 | "start": "gatsby develop", 8 | "develop": "gatsby develop", 9 | "serve": "gatsby build && gatsby serve", 10 | "format": "prettier --write '**/*.js'", 11 | "test": "echo \"Error: no test specified\" && exit 1", 12 | "clean": "rm -rf .cache && rm -rf public" 13 | }, 14 | "dependencies": { 15 | "gatsby": "next", 16 | "gatsby-plugin-netlify-cms": "^3.0.0-rc.0", 17 | "gatsby-plugin-react-helmet": "next", 18 | "gatsby-source-filesystem": "^2.0.1-rc.0", 19 | "gatsby-transformer-remark": "^2.1.1-rc.0", 20 | "marked": "^0.5.0", 21 | "netlify-cms": "^2.1.1", 22 | "react": "^16.5.0", 23 | "react-dom": "^16.5.0", 24 | "react-helmet": "^5.2.0", 25 | "slugify": "^1.3.1" 26 | }, 27 | "devDependencies": { 28 | "multer": "^1.3.1", 29 | "prettier": "^1.14.2" 30 | }, 31 | "license": "MIT", 32 | "repository": { 33 | "type": "git", 34 | "url": "https://github.com/bjrn/gatsby-with-netlify-cms" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /static/admin/config.yml: -------------------------------------------------------------------------------- 1 | development_overrides: 2 | # run file-system backend for local development 3 | backend: 4 | name: file-system 5 | api_root: /api 6 | # reset 'editorial_workflow' if enabled, since it breaks in file-system mode 7 | # publish_mode: '' 8 | 9 | # regular configuration 10 | backend: 11 | name: github 12 | squash_merges: true 13 | repo: bjrn/gatsby-with-netlify-cms 14 | branch: master 15 | 16 | display_url: https://gatsby-with-netlify-cms.netlify.com/ 17 | # publish_mode: editorial_workflow 18 | media_folder: static/assets 19 | public_folder: '/assets' 20 | 21 | slug: 22 | encoding: 'ascii' 23 | clean_accents: true 24 | 25 | collections: 26 | - name: articles 27 | label: Articles 28 | description: 'Articles' 29 | folder: content/articles/ 30 | slug: '{{slug}}' 31 | create: true 32 | fields: 33 | - { label: Title, name: title, widget: string, tagname: h1 } 34 | - { label: 'Author', name: author, widget: string, required: false } 35 | - { 36 | label: Publish Date, 37 | name: date, 38 | widget: date, 39 | format: 'YYYY-MM-DD', 40 | dateFormat: 'YYYY-MM-DD', 41 | required: false, 42 | } 43 | - { label: Body, name: body, widget: markdown } 44 | -------------------------------------------------------------------------------- /content/articles/post-2.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Post 2 3 | author: bjrn 4 | date: '2018-09-06' 5 | --- 6 | **Justo eget magna fermentum iaculis eu non.** Ultrices gravida dictum fusce ut placerat orci nulla pellentesque. Nunc sed blandit libero volutpat sed cras ornare arcu dui. Dictum sit amet justo donec enim. Lobortis mattis aliquam faucibus purus in massa tempor nec. Facilisis sed odio morbi quis commodo odio aenean sed. In cursus turpis massa tincidunt dui ut ornare lectus sit. Ante metus dictum at tempor commodo ullamcorper a lacus. Ut sem viverra aliquet eget sit amet tellus cras. At tempor commodo ullamcorper a lacus. Quis ipsum suspendisse ultrices gravida dictum fusce. Mauris ultrices eros in cursus. Facilisis magna etiam tempor orci eu lobortis elementum nibh tellus. Dapibus ultrices in iaculis nunc sed augue lacus. 7 | 8 | Scelerisque fermentum dui faucibus in ornare. Convallis tellus id interdum velit laoreet id donec. Neque ornare aenean euismod elementum nisi quis. Sit amet aliquam id diam maecenas ultricies mi. Odio aenean sed adipiscing diam donec adipiscing tristique. Cras pulvinar mattis nunc sed. Nisi quis eleifend quam adipiscing vitae proin sagittis. Ullamcorper malesuada proin libero nunc consequat. A pellentesque sit amet porttitor eget dolor. Vestibulum morbi blandit cursus risus at ultrices mi tempus. 9 | -------------------------------------------------------------------------------- /src/cms/cms.js: -------------------------------------------------------------------------------- 1 | import CMS, { init } from 'netlify-cms'; 2 | import EditorYoutube from './components/EditorYoutube'; 3 | import FileSystemBackend from './components/FileSystemBackend'; 4 | 5 | // console.log('CMS.config', config) 6 | // rewrite config to use file-system instead 7 | if (process.env.NODE_ENV === 'development') { 8 | // override certain ascpects of the config: 9 | window.CMS_ENV = 'development_overrides'; 10 | CMS.registerBackend('file-system', FileSystemBackend); 11 | } 12 | 13 | CMS.registerEditorComponent(EditorYoutube); 14 | 15 | // do manual init (accepts a config object) 16 | // init({ config: {...} }) that would be merged with 17 | // the config.yml settings, but this doesn't currently work 18 | // as expected. instead setting window.CMS_ENV and including 19 | // development_overrides in config.yml 20 | init(); 21 | 22 | // another option would be to render the config as json and 23 | // add it to the page via: 24 | // window.CMS_CONFIG = { /* JSON */ } 25 | 26 | // const fs = require('fs') 27 | // const yaml = require('js-yaml') 28 | // function getConfig() { 29 | // const file = `${__dirname}/static/admin/config.yml` 30 | // obj = yaml.load(fs.readFileSync(file, { encoding: 'utf-8' })) 31 | // // override obj as neccessary 32 | // window.CMS_CONFIG = JSON.stringify(obj); 33 | // } 34 | -------------------------------------------------------------------------------- /src/cms/file-system-api-plugin/README.md: -------------------------------------------------------------------------------- 1 | (WIP) The project uses the beta feature of manually initializing the CMS (valid in 1.4.0) and is now fully supported. 2 | 3 | This example repository has many layers, but the main entry point is the master branch. Other branches will hold examples we feel will also help you get started to design your custom NetlifyCMS. 4 | 5 | Have fun Exploring! 🎉 6 | 7 | ## Branches: 8 | 9 | - [(master)][master] - example from core NetlifyCMS project (1.5.0) 10 | Now using the local file-system (custom backend) for development, so you can test your configs, etc. 11 | - [(with-routes)][with-routes] - same as master with routes (With a Caveat) 12 | Must use page reload/replace when calling the CMS within a route. The CMS maintains it's own routes based on collections at this time so CMS routes require a page reload to the CMS route. `NetlifyCMS` 13 | 14 | ***NOTE:*** 15 | This project was bootstrapped with [Create React App](https://github.com/facebookincubator/create-react-app). 16 | You can find the most recent version of the guide [here](https://github.com/facebookincubator/create-react-app/blob/master/packages/react-scripts/template/README.md). 17 | 18 | Always code like you are on 🔥 19 | 20 | [master]: https://github.com/talves/netlify-cms-react-example/tree/master 21 | [with-routes]: https://github.com/talves/netlify-cms-react-example/tree/with-routes 22 | -------------------------------------------------------------------------------- /static/assets/media/netlify_logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | Netlify 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /content/articles/test.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Post 1 3 | author: bjrn 4 | date: '2018-09-04' 5 | --- 6 | Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Euismod elementum nisi quis eleifend quam adipiscing vitae proin sagittis. Pellentesque pulvinar pellentesque habitant morbi tristique. Viverra suspendisse potenti nullam ac tortor vitae purus. Ut diam quam nulla porttitor massa id neque aliquam. Sem et tortor consequat id porta nibh venenatis cras sed. At consectetur lorem donec massa sapien faucibus et. Felis imperdiet proin fermentum leo vel orci porta non. Felis eget velit aliquet sagittis id. Diam quis enim lobortis scelerisque. Mauris cursus mattis molestie a. Accumsan sit amet nulla facilisi morbi tempus. Id faucibus nisl tincidunt eget. 7 | 8 | Ac tincidunt vitae semper quis lectus. Dui faucibus in ornare quam viverra orci sagittis. Vulputate mi sit amet mauris. Turpis egestas integer eget aliquet nibh praesent. Netus et malesuada fames ac turpis egestas maecenas pharetra convallis. Quam pellentesque nec nam aliquam sem et tortor consequat. Adipiscing at in tellus integer feugiat scelerisque. Risus nullam eget felis eget. Pharetra sit amet aliquam id diam maecenas. Tempus iaculis urna id volutpat lacus laoreet. Habitant morbi tristique senectus et netus. Platea dictumst vestibulum rhoncus est. Mi sit amet mauris commodo. Tincidunt dui ut ornare lectus sit. Non quam lacus suspendisse faucibus. Adipiscing tristique risus nec feugiat. 9 | 10 | Arcu non sodales neque sodales ut etiam sit amet. Ornare arcu odio ut sem nulla. Ut faucibus pulvinar elementum integer. Ornare lectus sit amet est placerat in egestas erat imperdiet. Non nisi est sit amet facilisis magna. Vitae aliquet nec ullamcorper sit amet risus nullam. Venenatis a condimentum vitae sapien pellentesque habitant morbi tristique. Adipiscing elit pellentesque habitant morbi tristique senectus et netus. Netus et malesuada fames ac. Sodales ut etiam sit amet. Sapien faucibus et molestie ac feugiat sed lectus vestibulum. Suspendisse interdum consectetur libero id faucibus nisl tincidunt. Ultrices eros in cursus turpis massa tincidunt dui. 11 | -------------------------------------------------------------------------------- /gatsby-node.js: -------------------------------------------------------------------------------- 1 | const { createFilePath } = require(`gatsby-source-filesystem`); 2 | const path = require(`path`); 3 | 4 | exports.onCreateNode = ({ node, getNode, actions }) => { 5 | const { createNodeField } = actions; 6 | if (node.internal.type === `MarkdownRemark`) { 7 | const slug = createFilePath({ node, getNode, basePath: `pages` }); 8 | createNodeField({ 9 | node, 10 | name: `slug`, 11 | value: slug, 12 | }); 13 | } 14 | }; 15 | 16 | exports.createPages = ({ graphql, actions }) => { 17 | const { createPage } = actions; 18 | return new Promise((resolve, reject) => { 19 | graphql(` 20 | { 21 | allMarkdownRemark { 22 | edges { 23 | node { 24 | fields { 25 | slug 26 | } 27 | } 28 | } 29 | } 30 | } 31 | `).then(result => { 32 | result.data.allMarkdownRemark.edges.forEach(({ node }) => { 33 | createPage({ 34 | path: node.fields.slug, 35 | component: path.resolve(`./src/templates/blog-post.js`), 36 | context: { 37 | // Data passed to context is available 38 | // in page queries as GraphQL variables. 39 | slug: node.fields.slug, 40 | }, 41 | }); 42 | }); 43 | resolve(); 44 | }); 45 | }); 46 | }; 47 | 48 | exports.onCreateWebpackConfig = ({ loaders, actions, stage }) => { 49 | var config = {}; 50 | 51 | if (stage === 'build-javascript') { 52 | // turn off source-maps 53 | config.devtool = false; 54 | } 55 | 56 | // Previously this was needed, but looks like it has been fixed 57 | // in more recent versions of netlify-cms 58 | // One solution is to customize your webpack configuration to replace the offending module with a dummy module during server rendering. 59 | // gatsbyjs.org/docs/debugging-html-builds/#fixing-third-party-modules 60 | // if (stage === 'build-html') { 61 | // config.module = { 62 | // rules: [ 63 | // { 64 | // test: /(netlify-cms)/, 65 | // use: loaders.null(), 66 | // }, 67 | // ], 68 | // }; 69 | // } 70 | 71 | actions.setWebpackConfig(config); 72 | }; 73 | -------------------------------------------------------------------------------- /src/cms/components/FileSystemBackend/lib/pathHelper.js: -------------------------------------------------------------------------------- 1 | const absolutePath = new RegExp('^(?:[a-z]+:)?//', 'i'); 2 | // eslint-disable-next-line 3 | const normalizePath = path => path.replace(/[\\\/]+/g, '/'); 4 | 5 | export function resolvePath(path, basePath) { // eslint-disable-line 6 | // No path provided, skip 7 | if (!path) return null; 8 | 9 | // It's an absolute path. 10 | if (absolutePath.test(path)) return path; 11 | 12 | if (path.indexOf('/') === -1) { 13 | // It's a single file name, no directories. Prepend public folder 14 | return normalizePath(`/${ basePath }/${ path }`); 15 | } 16 | 17 | // It's a relative path. Prepend a forward slash. 18 | return normalizePath(`/${ path }`); 19 | } 20 | 21 | /** 22 | * Return the last portion of a path. Similar to the Unix basename command. 23 | * @example Usage example 24 | * path.basename('/foo/bar/baz/asdf/quux.html') 25 | * // returns 26 | * 'quux.html' 27 | * 28 | * path.basename('/foo/bar/baz/asdf/quux.html', '.html') 29 | * // returns 30 | * 'quux' 31 | */ 32 | export function basename(p, ext = "") { 33 | // Special case: Normalize will modify this to '.' 34 | if (p === '') { 35 | return p; 36 | } 37 | // Normalize the string first to remove any weirdness. 38 | p = normalizePath(p); 39 | // Get the last part of the string. 40 | const sections = p.split('/'); 41 | const lastPart = sections[sections.length - 1]; 42 | // Special case: If it's empty, then we have a string like so: foo/ 43 | // Meaning, 'foo' is guaranteed to be a directory. 44 | if (lastPart === '' && sections.length > 1) { 45 | return sections[sections.length - 2]; 46 | } 47 | // Remove the extension, if need be. 48 | if (ext.length > 0) { 49 | const lastPartExt = lastPart.substr(lastPart.length - ext.length); 50 | if (lastPartExt === ext) { 51 | return lastPart.substr(0, lastPart.length - ext.length); 52 | } 53 | } 54 | return lastPart; 55 | } 56 | 57 | /** 58 | * Return the extension of the path, from the last '.' to end of string in the 59 | * last portion of the path. If there is no '.' in the last portion of the path 60 | * or the first character of it is '.', then it returns an empty string. 61 | * @example Usage example 62 | * path.fileExtensionWithSeparator('index.html') 63 | * // returns 64 | * '.html' 65 | */ 66 | export function fileExtensionWithSeparator(p) { 67 | p = normalizePath(p); 68 | const sections = p.split('/'); 69 | p = sections.pop(); 70 | // Special case: foo/file.ext/ should return '.ext' 71 | if (p === '' && sections.length > 0) { 72 | p = sections.pop(); 73 | } 74 | if (p === '..') { 75 | return ''; 76 | } 77 | const i = p.lastIndexOf('.'); 78 | if (i === -1 || i === 0) { 79 | return ''; 80 | } 81 | return p.substr(i); 82 | } 83 | 84 | /** 85 | * Return the extension of the path, from after the last '.' to end of string in the 86 | * last portion of the path. If there is no '.' in the last portion of the path 87 | * or the first character of it is '.', then it returns an empty string. 88 | * @example Usage example 89 | * path.fileExtension('index.html') 90 | * // returns 91 | * 'html' 92 | */ 93 | export function fileExtension(p) { 94 | const ext = fileExtensionWithSeparator(p); 95 | return ext === '' ? ext : ext.substr(1); 96 | } 97 | -------------------------------------------------------------------------------- /src/cms/file-system-api-plugin/fs-api.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | 4 | const projectRoot = path.join(__dirname, '../../../') 5 | console.log(`Root path is ${projectRoot}`) 6 | 7 | let siteRel = 'example' 8 | const siteRoot = { 9 | dir: path.join(projectRoot, siteRel), 10 | } 11 | const setPath = relPath => { 12 | siteRel = relPath 13 | siteRoot.dir = path.join(projectRoot, siteRel) 14 | console.log(`Site path is ${siteRoot.dir}`) 15 | } 16 | 17 | module.exports = { 18 | site: { setPath }, 19 | files: dirname => { 20 | const name = 'Files' 21 | const read = cb => { 22 | if (!cb) 23 | throw new Error( 24 | 'Invalid call to files.read - requires a callback function(content)' 25 | ) 26 | const thispath = path.join(siteRoot.dir, dirname) 27 | const files = fs.existsSync(thispath) ? fs.readdirSync(thispath) : [] 28 | const filelist = [] 29 | files.forEach(function(element) { 30 | const filePath = path.join(thispath, element) 31 | const stats = fs.statSync(filePath) 32 | if (stats.isFile()) { 33 | filelist.push({ 34 | name: element, 35 | path: `${dirname}/${element}`, 36 | stats, 37 | type: 'file', 38 | }) 39 | } 40 | }, this) 41 | cb(filelist) 42 | } 43 | return { read, name } 44 | }, 45 | file: id => { 46 | const name = 'File' 47 | const thisfile = path.join(siteRoot.dir, id) 48 | let stats 49 | try { 50 | stats = fs.statSync(thisfile) 51 | } catch (err) { 52 | stats = {} 53 | } 54 | 55 | /* GET-Read an existing file */ 56 | const read = cb => { 57 | if (!cb) 58 | throw new Error( 59 | 'Invalid call to file.read - requires a callback function(content)' 60 | ) 61 | if (stats.isFile()) { 62 | fs.readFile(thisfile, 'utf8', (err, data) => { 63 | if (err) { 64 | cb({ error: err }) 65 | } else { 66 | cb(data) 67 | } 68 | }) 69 | } else { 70 | throw new Error( 71 | 'Invalid call to file.read - object path is not a file!' 72 | ) 73 | } 74 | } 75 | /* POST-Create a NEW file, ERROR if exists */ 76 | const create = (body, cb) => { 77 | fs.writeFile( 78 | thisfile, 79 | body.content, 80 | { encoding: body.encoding, flag: 'wx' }, 81 | err => { 82 | if (err) { 83 | cb({ error: err }) 84 | } else { 85 | cb(body.content) 86 | } 87 | } 88 | ) 89 | } 90 | /* PUT-Update an existing file */ 91 | const update = (body, cb) => { 92 | fs.writeFile( 93 | thisfile, 94 | body.content, 95 | { encoding: body.encoding, flag: 'w' }, 96 | err => { 97 | if (err) { 98 | cb({ error: err }) 99 | } else { 100 | cb(body.content) 101 | } 102 | } 103 | ) 104 | } 105 | /* DELETE an existing file */ 106 | const del = cb => { 107 | fs.unlink(thisfile, err => { 108 | if (err) { 109 | cb({ error: err }) 110 | } else { 111 | cb(`Deleted File ${thisfile}`) 112 | } 113 | }) 114 | } 115 | return { read, create, update, del, stats } 116 | }, 117 | } 118 | -------------------------------------------------------------------------------- /src/cms/components/FileSystemBackend/implementation.js: -------------------------------------------------------------------------------- 1 | import trimStart from 'lodash/trimStart' 2 | import AuthenticationPage from './AuthenticationPage' 3 | import API from './API' 4 | import { fileExtension } from './lib/pathHelper' 5 | 6 | if (typeof window !== `undefined`) { 7 | window.repoFiles = window.repoFiles || {} 8 | } 9 | 10 | const nameFromEmail = email => { 11 | return email 12 | .split('@') 13 | .shift() 14 | .replace(/[.-_]/g, ' ') 15 | .split(' ') 16 | .filter(f => f) 17 | .map(s => s.substr(0, 1).toUpperCase() + (s.substr(1) || '')) 18 | .join(' ') 19 | } 20 | 21 | export default class fs { 22 | constructor(config) { 23 | this.config = config 24 | console.log('authComponent()') 25 | this.api_root = config.getIn( 26 | ['backend', 'api_root'], 27 | 'http://localhost:8080/api' 28 | ) 29 | } 30 | 31 | authComponent() { 32 | console.log('authComponent()') 33 | return AuthenticationPage 34 | } 35 | 36 | restoreUser(user) { 37 | return this.authenticate(user) 38 | } 39 | 40 | authenticate(state) { 41 | this.api = new API({ api_root: this.api_root }) 42 | return Promise.resolve({ 43 | email: state.email, 44 | name: nameFromEmail(state.email), 45 | }) 46 | } 47 | 48 | logout() { 49 | return null 50 | } 51 | 52 | getToken() { 53 | return Promise.resolve('') 54 | } 55 | 56 | entriesByFolder(collection, extension) { 57 | return this.api 58 | .listFiles(collection.get('folder')) 59 | .then(files => 60 | files.filter(file => fileExtension(file.name) === extension) 61 | ) 62 | .then(this.fetchFiles) 63 | } 64 | 65 | entriesByFiles(collection) { 66 | const files = collection.get('files').map(collectionFile => ({ 67 | path: collectionFile.get('file'), 68 | label: collectionFile.get('label'), 69 | })) 70 | return this.fetchFiles(files) 71 | } 72 | 73 | fetchFiles = files => { 74 | const promises = [] 75 | files.forEach(file => { 76 | promises.push( 77 | new Promise((resolve, reject) => 78 | this.api 79 | .readFile(file.path) 80 | .then(data => { 81 | resolve({ file, data }) 82 | }) 83 | .catch(err => { 84 | reject(err) 85 | }) 86 | ) 87 | ) 88 | }) 89 | return Promise.all(promises) 90 | } 91 | 92 | getEntry(collection, slug, path) { 93 | return this.api.readFile(path).then(data => ({ 94 | file: { path }, 95 | data, 96 | })) 97 | } 98 | 99 | getMedia() { 100 | return this.api 101 | .listFiles(this.config.get('media_folder')) 102 | .then(files => files.filter(file => file.type === 'file')) 103 | .then(files => 104 | files.map(({ sha, name, size, stats, path }) => { 105 | return { 106 | id: sha, 107 | name, 108 | size: stats.size, 109 | url: `${this.config.get('public_folder')}/${name}`, 110 | path, 111 | } 112 | }) 113 | ) 114 | } 115 | 116 | persistEntry(entry, mediaFiles = [], options = {}) { 117 | return this.api.persistFiles(entry, mediaFiles, options) 118 | } 119 | 120 | async persistMedia(mediaFile, options = {}) { 121 | try { 122 | const response = await this.api.persistFiles([], [mediaFile], options) 123 | const { value, path, public_path, fileObj } = mediaFile 124 | const url = public_path 125 | return { 126 | id: response.sha, 127 | name: value, 128 | size: fileObj.size, 129 | url, 130 | path: trimStart(path, '/'), 131 | } 132 | } catch (error) { 133 | console.error(error) 134 | throw error 135 | } 136 | } 137 | 138 | deleteFile(path, commitMessage, options) { 139 | return this.api.deleteFile(path, commitMessage, options) 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/cms/components/FileSystemBackend/API.js: -------------------------------------------------------------------------------- 1 | import { Base64 } from 'js-base64'; 2 | import APIError from './lib/APIError'; 3 | const SIMPLE = 'simple'; 4 | 5 | export default class API { 6 | constructor(config) { 7 | this.api_root = config.api_root || '/api'; 8 | } 9 | 10 | user() { 11 | console.log('API::user()'); 12 | return this.request('/user'); 13 | } 14 | 15 | requestHeaders(headers = {}) { 16 | const baseHeader = { 17 | 'Content-Type': 'application/json', 18 | ...headers, 19 | }; 20 | 21 | return baseHeader; 22 | } 23 | 24 | parseJsonResponse(response) { 25 | return response.json().then(json => { 26 | if (!response.ok) { 27 | return Promise.reject(json); 28 | } 29 | 30 | return json; 31 | }); 32 | } 33 | 34 | urlFor(path, options) { 35 | const cacheBuster = new Date().getTime(); 36 | const params = [`ts=${cacheBuster}`]; 37 | if (options.params) { 38 | for (const key in options.params) { 39 | params.push(`${key}=${encodeURIComponent(options.params[key])}`); 40 | } 41 | } 42 | if (params.length) { 43 | path += `?${params.join('&')}`; 44 | } 45 | return this.api_root + path; 46 | } 47 | 48 | request(path, options = {}) { 49 | const headers = this.requestHeaders(options.headers || {}); 50 | const url = this.urlFor(path, options); 51 | let responseStatus; 52 | return fetch(url, { ...options, headers }) 53 | .then(response => { 54 | responseStatus = response.status; 55 | const contentType = response.headers.get('Content-Type'); 56 | if (contentType && contentType.match(/json/)) { 57 | return this.parseJsonResponse(response); 58 | } 59 | return response.text(); 60 | }) 61 | .catch(error => { 62 | throw new APIError(error.message, responseStatus, 'fs'); 63 | }); 64 | } 65 | 66 | readFile(path) { 67 | const cache = Promise.resolve(null); 68 | return cache.then(cached => { 69 | if (cached) { 70 | return cached; 71 | } 72 | 73 | return this.request(`/file/${path}`, { 74 | headers: { Accept: 'application/octet-stream' }, 75 | params: {}, 76 | cache: 'no-store', 77 | }).then(result => { 78 | return result; 79 | }); 80 | }); 81 | } 82 | 83 | listFiles(path) { 84 | return this.request(`/files/${path}`, { 85 | params: {}, 86 | }) 87 | .then(files => { 88 | if (!Array.isArray(files)) { 89 | throw new Error( 90 | `Cannot list files, path ${path} is not a directory but a ${ 91 | files.type 92 | }` 93 | ); 94 | } 95 | return files; 96 | }) 97 | .then(files => files.filter(file => file.type === 'file')); 98 | } 99 | 100 | composeFileTree(files) { 101 | let filename; 102 | let part; 103 | let parts; 104 | let subtree; 105 | const fileTree = {}; 106 | 107 | files.forEach(file => { 108 | if (file.uploaded) { 109 | return; 110 | } 111 | parts = file.path.split('/').filter(part => part); 112 | filename = parts.pop(); 113 | subtree = fileTree; 114 | while (part === parts.shift()) { 115 | subtree[part] = subtree[part] || {}; 116 | subtree = subtree[part]; 117 | } 118 | subtree[filename] = file; 119 | file.file = true; 120 | }); 121 | 122 | return fileTree; 123 | } 124 | 125 | toBase64(str) { 126 | return Promise.resolve(Base64.encode(str)); 127 | } 128 | 129 | uploadBlob(item, newFile = false) { 130 | const content = item.raw ? this.toBase64(item.raw) : item.toBase64(); 131 | const method = newFile ? 'POST' : 'PUT'; // Always update or create new. PUT is Update existing only 132 | 133 | const pathID = 134 | item.path.substring(0, 1) === '/' 135 | ? item.path.substring(1, item.path.length) 136 | : item.path.toString(); 137 | 138 | return content.then(contentBase64 => 139 | this.request(`/file/${pathID}`, { 140 | method: method, 141 | body: JSON.stringify({ 142 | content: contentBase64, 143 | encoding: 'base64', 144 | }), 145 | }).then(response => { 146 | item.uploaded = true; 147 | return item; 148 | }) 149 | ); 150 | } 151 | 152 | persistFiles(entry, mediaFiles, options) { 153 | const uploadPromises = []; 154 | const files = mediaFiles.concat(entry); 155 | 156 | files.forEach(file => { 157 | if (file.uploaded) { 158 | return; 159 | } 160 | uploadPromises.push( 161 | this.uploadBlob(file, options.newEntry && !file.toBase64) 162 | ); 163 | }); 164 | 165 | const fileTree = this.composeFileTree(files); 166 | 167 | return Promise.all(uploadPromises).then(() => { 168 | if (!options.mode || (options.mode && options.mode === SIMPLE)) { 169 | return fileTree; 170 | } 171 | }); 172 | } 173 | 174 | deleteFile(path, message, options = {}) { 175 | const fileURL = `/file/${path}`; 176 | return this.request(fileURL, { 177 | method: 'DELETE', 178 | params: {}, 179 | }); 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /src/cms/file-system-api-plugin/fs-express-api.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This file is to be used by node. There is no file system access at the client. 3 | * To be called from our webpack devServer.setup config 4 | * See: http://expressjs.com/en/guide/using-middleware.html 5 | * const fsExpressAPI = require('./scripts/fs/fs-express-api'); 6 | * devServer: { 7 | * . 8 | * . 9 | * setup: fsExpressAPI, 10 | * }, 11 | **/ 12 | const bodyParser = require('body-parser'); 13 | const multer = require('multer'); 14 | const fsAPI = require('./fs-api'); 15 | const fsPath = ''; 16 | 17 | /* Express allows for app object setup to handle paths (our api routes) */ 18 | module.exports = function(app) { 19 | fsAPI.site.setPath(fsPath); 20 | const upload = multer(); // for parsing multipart/form-data 21 | const uploadLimit = '50mb'; // express has a default of ~20Kb 22 | app.use(bodyParser.json({ limit: uploadLimit })); // for parsing application/json 23 | app.use(bodyParser.urlencoded({ limit: uploadLimit, extended: true, parameterLimit:50000 })); // for parsing application/x-www-form-urlencoded 24 | 25 | // We will look at every route to bypass any /api route from the react app 26 | app.use('/:path', function(req, res, next) { 27 | // if the path is api, skip to the next route 28 | if (req.params.path === 'api') { 29 | next('route'); 30 | } 31 | // otherwise pass the control out of this middleware to the next middleware function in this stack (back to regular) 32 | else next(); 33 | }); 34 | 35 | app.use('/api', function(req, res, next) { 36 | const response = { route: '/api', url: req.originalUrl }; 37 | if (req.originalUrl === "/api" || req.originalUrl === "/api/") { 38 | // if the requested url is the root, , respond Error! 39 | response.status = 500; 40 | response.error = 'This is the root of the API'; 41 | res.status(response.status).json(response); 42 | } else { 43 | // continue to the next sub-route ('/api/:path') 44 | next('route'); 45 | } 46 | }); 47 | 48 | /* Define custom handlers for api paths: */ 49 | app.use('/api/:path', function(req, res, next) { 50 | const response = { route: '/api/:path', path: req.params.path, params: req.params }; 51 | if (req.params.path && req.params.path in fsAPI) { 52 | // all good, route exists in the api 53 | next('route'); 54 | } else { 55 | // sub-route was not found in the api, respond Error! 56 | response.status = 500; 57 | response.error = `Invalid path ${ req.params.path }`; 58 | res.status(response.status).json(response); 59 | } 60 | }); 61 | 62 | /* Files */ 63 | 64 | /* Return all the files in the starting path */ 65 | app.get('/api/files', function(req, res, next) { 66 | const response = { route: '/api/files' }; 67 | try { 68 | fsAPI.files('./').read((contents) => { 69 | res.json(contents); 70 | }); 71 | } catch (err) { 72 | response.status = 500; 73 | response.error = `Could not get files - code [${ err.code }]`; 74 | response.internalError = err; 75 | res.status(response.status).send(response); 76 | } 77 | }); 78 | 79 | /* Return all the files in the passed path */ 80 | app.get('/api/files/:path', function(req, res, next) { 81 | const response = { route: '/api/files/:path', params: req.params, path: req.params.path }; 82 | try { 83 | fsAPI.files(req.params.path).read((contents) => { 84 | res.json(contents); 85 | }); 86 | } catch (err) { 87 | response.status = 500; 88 | response.error = `Could not get files for ${ req.params.path } - code [${ err.code }]`; 89 | response.internalError = err; 90 | res.status(response.status).send(response); 91 | } 92 | }); 93 | /* Capture Unknown extras and handle path (ignore?) */ 94 | app.get('/api/files/:path/**', function(req, res, next) { 95 | const response = { route: '/api/files/:path/**', params: req.params, path: req.params.path }; 96 | const filesPath = req.originalUrl.substring(11, req.originalUrl.split('?', 1)[0].length); 97 | try { 98 | fsAPI.files(filesPath).read((contents) => { 99 | res.json(contents); 100 | }); 101 | } catch (err) { 102 | response.status = 500; 103 | response.error = `Could not get files for ${ filesPath } - code [${ err.code }]`; 104 | response.internalError = err; 105 | res.status(response.status).send(response); 106 | } 107 | }); 108 | 109 | /* File */ 110 | 111 | app.get('/api/file', function(req, res, next) { 112 | const response = { error: 'Id cannot be empty for file', status: 500, path: res.path }; 113 | res.status(response.status).send(response); 114 | }); 115 | 116 | app.get('/api/file/:id', function(req, res, next) { 117 | const response = { route: '/api/file/:id', id: req.params.id }; 118 | const allDone = (contents) => { 119 | if (contents.error) { 120 | response.status = 500; 121 | response.error = `Could not read file ${ req.params.id } - code [${ contents.error.code }]`; 122 | response.internalError = contents.error; 123 | res.status(response.status).send(response); 124 | } else { 125 | res.json(contents); 126 | } 127 | }; 128 | if (req.params.id) { 129 | fsAPI.file(req.params.id).read(allDone); 130 | } else { 131 | response.status = 500; 132 | response.error = `Invalid id for File ${ req.params.id }`; 133 | res.status(response.status).send(response); 134 | } 135 | }); 136 | /* Capture Unknown extras and ignore the rest */ 137 | app.get('/api/file/:id/**', function(req, res, next) { 138 | const response = { route: '/api/file/:id', id: req.params.id, method:req.method }; 139 | const filePath = req.originalUrl.substring(10, req.originalUrl.split('?', 1)[0].length); 140 | const allDone = (contents) => { 141 | if (contents.error) { 142 | response.status = 500; 143 | response.error = `Could not read file ${ filePath } - code [${ contents.error.code }]`; 144 | response.internalError = contents.error; 145 | res.status(response.status).send(response); 146 | } else { 147 | res.json(contents); 148 | } 149 | }; 150 | if (filePath) { 151 | fsAPI.file(filePath).read(allDone); 152 | } else { 153 | response.status = 500; 154 | response.error = `Invalid path for File ${ filePath }`; 155 | res.status(response.status).send(response); 156 | } 157 | }); 158 | /* Create file if path does not exist */ 159 | app.post('/api/file/:id/**', upload.array(), function(req, res, next) { 160 | const response = { route: '/api/file/:id', id: req.params.id, method:req.method }; 161 | const filePath = req.originalUrl.substring(10, req.originalUrl.split('?', 1)[0].length); 162 | const allDone = (contents) => { 163 | if (contents.error) { 164 | response.status = 500; 165 | response.error = `Could not create file ${ filePath } - code [${ contents.error.code }]`; 166 | response.internalError = contents.error; 167 | res.status(response.status).send(response); 168 | } else { 169 | res.json(contents); 170 | } 171 | }; 172 | if (filePath) { 173 | fsAPI.file(filePath).create(req.body, allDone); 174 | } else { 175 | response.status = 500; 176 | response.error = `Invalid path for File ${ filePath }`; 177 | res.status(response.status).send(response); 178 | } 179 | }); 180 | /* Update file, error on path exists */ 181 | app.put('/api/file/:id/**', upload.array(), function(req, res, next) { 182 | const response = { route: '/api/file/:id', id: req.params.id, method:req.method }; 183 | const filePath = req.originalUrl.substring(10, req.originalUrl.split('?', 1)[0].length); 184 | const allDone = (contents) => { 185 | if (contents.error) { 186 | response.status = 500; 187 | response.error = `Could not update file ${ filePath } - code [${ contents.error.code }]`; 188 | response.internalError = contents.error; 189 | res.status(response.status).send(response); 190 | } else { 191 | res.json(contents); 192 | } 193 | }; 194 | if (filePath) { 195 | fsAPI.file(filePath).update(req.body, allDone); 196 | } else { 197 | response.status = 500; 198 | response.error = `Invalid path for File ${ filePath }`; 199 | res.status(response.status).send(response); 200 | } 201 | }); 202 | /* Delete file, error if no file */ 203 | app.delete('/api/file/:id/**', function(req, res, next) { 204 | const response = { route: '/api/file/:id', id: req.params.id, method:req.method }; 205 | const filePath = req.originalUrl.substring(10, req.originalUrl.split('?', 1)[0].length); 206 | const allDone = (contents) => { 207 | if (contents.error) { 208 | response.status = 500; 209 | response.error = `Could not delete file ${ filePath } - code [${ contents.error.code }]`; 210 | response.internalError = contents.error; 211 | res.status(response.status).send(response); 212 | } else { 213 | res.json(contents); 214 | } 215 | }; 216 | if (filePath) { 217 | fsAPI.file(filePath).del(allDone); 218 | } else { 219 | response.status = 500; 220 | response.error = `Invalid path for File ${ filePath }`; 221 | res.status(response.status).send(response); 222 | } 223 | }); 224 | }; 225 | -------------------------------------------------------------------------------- /src/components/layout.css: -------------------------------------------------------------------------------- 1 | html { 2 | font-family: sans-serif; 3 | -ms-text-size-adjust: 100%; 4 | -webkit-text-size-adjust: 100%; 5 | } 6 | 7 | nav a { 8 | display: inline-block; 9 | padding: 1em; 10 | } 11 | 12 | body { 13 | margin: 0; 14 | } 15 | article, 16 | aside, 17 | details, 18 | figcaption, 19 | figure, 20 | footer, 21 | header, 22 | main, 23 | menu, 24 | nav, 25 | section, 26 | summary { 27 | display: block; 28 | } 29 | audio, 30 | canvas, 31 | progress, 32 | video { 33 | display: inline-block; 34 | } 35 | audio:not([controls]) { 36 | display: none; 37 | height: 0; 38 | } 39 | progress { 40 | vertical-align: baseline; 41 | } 42 | [hidden], 43 | template { 44 | display: none; 45 | } 46 | a { 47 | background-color: transparent; 48 | -webkit-text-decoration-skip: objects; 49 | } 50 | a:active, 51 | a:hover { 52 | outline-width: 0; 53 | } 54 | abbr[title] { 55 | border-bottom: none; 56 | text-decoration: underline; 57 | text-decoration: underline dotted; 58 | } 59 | b, 60 | strong { 61 | font-weight: inherit; 62 | font-weight: bolder; 63 | } 64 | dfn { 65 | font-style: italic; 66 | } 67 | h1 { 68 | font-size: 2em; 69 | margin: 0.67em 0; 70 | } 71 | mark { 72 | background-color: #ff0; 73 | color: #000; 74 | } 75 | small { 76 | font-size: 80%; 77 | } 78 | sub, 79 | sup { 80 | font-size: 75%; 81 | line-height: 0; 82 | position: relative; 83 | vertical-align: baseline; 84 | } 85 | sub { 86 | bottom: -0.25em; 87 | } 88 | sup { 89 | top: -0.5em; 90 | } 91 | img { 92 | border-style: none; 93 | } 94 | svg:not(:root) { 95 | overflow: hidden; 96 | } 97 | code, 98 | kbd, 99 | pre, 100 | samp { 101 | font-family: monospace, monospace; 102 | font-size: 1em; 103 | } 104 | figure { 105 | margin: 1em 40px; 106 | } 107 | hr { 108 | box-sizing: content-box; 109 | height: 0; 110 | overflow: visible; 111 | } 112 | button, 113 | input, 114 | optgroup, 115 | select, 116 | textarea { 117 | font: inherit; 118 | margin: 0; 119 | } 120 | optgroup { 121 | font-weight: 700; 122 | } 123 | button, 124 | input { 125 | overflow: visible; 126 | } 127 | button, 128 | select { 129 | text-transform: none; 130 | } 131 | [type='reset'], 132 | [type='submit'], 133 | button, 134 | html [type='button'] { 135 | -webkit-appearance: button; 136 | } 137 | [type='button']::-moz-focus-inner, 138 | [type='reset']::-moz-focus-inner, 139 | [type='submit']::-moz-focus-inner, 140 | button::-moz-focus-inner { 141 | border-style: none; 142 | padding: 0; 143 | } 144 | [type='button']:-moz-focusring, 145 | [type='reset']:-moz-focusring, 146 | [type='submit']:-moz-focusring, 147 | button:-moz-focusring { 148 | outline: 1px dotted ButtonText; 149 | } 150 | fieldset { 151 | border: 1px solid silver; 152 | margin: 0 2px; 153 | padding: 0.35em 0.625em 0.75em; 154 | } 155 | legend { 156 | box-sizing: border-box; 157 | color: inherit; 158 | display: table; 159 | max-width: 100%; 160 | padding: 0; 161 | white-space: normal; 162 | } 163 | textarea { 164 | overflow: auto; 165 | } 166 | [type='checkbox'], 167 | [type='radio'] { 168 | box-sizing: border-box; 169 | padding: 0; 170 | } 171 | [type='number']::-webkit-inner-spin-button, 172 | [type='number']::-webkit-outer-spin-button { 173 | height: auto; 174 | } 175 | [type='search'] { 176 | -webkit-appearance: textfield; 177 | outline-offset: -2px; 178 | } 179 | [type='search']::-webkit-search-cancel-button, 180 | [type='search']::-webkit-search-decoration { 181 | -webkit-appearance: none; 182 | } 183 | ::-webkit-input-placeholder { 184 | color: inherit; 185 | opacity: 0.54; 186 | } 187 | ::-webkit-file-upload-button { 188 | -webkit-appearance: button; 189 | font: inherit; 190 | } 191 | html { 192 | font: 112.5%/1.45em georgia, serif; 193 | box-sizing: border-box; 194 | overflow-y: scroll; 195 | } 196 | * { 197 | box-sizing: inherit; 198 | } 199 | *:before { 200 | box-sizing: inherit; 201 | } 202 | *:after { 203 | box-sizing: inherit; 204 | } 205 | body { 206 | color: hsla(0, 0%, 0%, 0.8); 207 | font-family: georgia, serif; 208 | font-weight: normal; 209 | word-wrap: break-word; 210 | font-kerning: normal; 211 | -moz-font-feature-settings: 'kern', 'liga', 'clig', 'calt'; 212 | -ms-font-feature-settings: 'kern', 'liga', 'clig', 'calt'; 213 | -webkit-font-feature-settings: 'kern', 'liga', 'clig', 'calt'; 214 | font-feature-settings: 'kern', 'liga', 'clig', 'calt'; 215 | } 216 | img { 217 | max-width: 100%; 218 | margin-left: 0; 219 | margin-right: 0; 220 | margin-top: 0; 221 | padding-bottom: 0; 222 | padding-left: 0; 223 | padding-right: 0; 224 | padding-top: 0; 225 | margin-bottom: 1.45rem; 226 | } 227 | h1 { 228 | margin-left: 0; 229 | margin-right: 0; 230 | margin-top: 0; 231 | padding-bottom: 0; 232 | padding-left: 0; 233 | padding-right: 0; 234 | padding-top: 0; 235 | margin-bottom: 1.45rem; 236 | color: inherit; 237 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, 238 | Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; 239 | font-weight: bold; 240 | text-rendering: optimizeLegibility; 241 | font-size: 2.25rem; 242 | line-height: 1.1; 243 | } 244 | h2 { 245 | margin-left: 0; 246 | margin-right: 0; 247 | margin-top: 0; 248 | padding-bottom: 0; 249 | padding-left: 0; 250 | padding-right: 0; 251 | padding-top: 0; 252 | margin-bottom: 1.45rem; 253 | color: inherit; 254 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, 255 | Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; 256 | font-weight: bold; 257 | text-rendering: optimizeLegibility; 258 | font-size: 1.62671rem; 259 | line-height: 1.1; 260 | } 261 | h3 { 262 | margin-left: 0; 263 | margin-right: 0; 264 | margin-top: 0; 265 | padding-bottom: 0; 266 | padding-left: 0; 267 | padding-right: 0; 268 | padding-top: 0; 269 | margin-bottom: 1.45rem; 270 | color: inherit; 271 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, 272 | Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; 273 | font-weight: bold; 274 | text-rendering: optimizeLegibility; 275 | font-size: 1.38316rem; 276 | line-height: 1.1; 277 | } 278 | h4 { 279 | margin-left: 0; 280 | margin-right: 0; 281 | margin-top: 0; 282 | padding-bottom: 0; 283 | padding-left: 0; 284 | padding-right: 0; 285 | padding-top: 0; 286 | margin-bottom: 1.45rem; 287 | color: inherit; 288 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, 289 | Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; 290 | font-weight: bold; 291 | text-rendering: optimizeLegibility; 292 | font-size: 1rem; 293 | line-height: 1.1; 294 | } 295 | h5 { 296 | margin-left: 0; 297 | margin-right: 0; 298 | margin-top: 0; 299 | padding-bottom: 0; 300 | padding-left: 0; 301 | padding-right: 0; 302 | padding-top: 0; 303 | margin-bottom: 1.45rem; 304 | color: inherit; 305 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, 306 | Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; 307 | font-weight: bold; 308 | text-rendering: optimizeLegibility; 309 | font-size: 0.85028rem; 310 | line-height: 1.1; 311 | } 312 | h6 { 313 | margin-left: 0; 314 | margin-right: 0; 315 | margin-top: 0; 316 | padding-bottom: 0; 317 | padding-left: 0; 318 | padding-right: 0; 319 | padding-top: 0; 320 | margin-bottom: 1.45rem; 321 | color: inherit; 322 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, 323 | Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; 324 | font-weight: bold; 325 | text-rendering: optimizeLegibility; 326 | font-size: 0.78405rem; 327 | line-height: 1.1; 328 | } 329 | hgroup { 330 | margin-left: 0; 331 | margin-right: 0; 332 | margin-top: 0; 333 | padding-bottom: 0; 334 | padding-left: 0; 335 | padding-right: 0; 336 | padding-top: 0; 337 | margin-bottom: 1.45rem; 338 | } 339 | ul { 340 | margin-left: 1.45rem; 341 | margin-right: 0; 342 | margin-top: 0; 343 | padding-bottom: 0; 344 | padding-left: 0; 345 | padding-right: 0; 346 | padding-top: 0; 347 | margin-bottom: 1.45rem; 348 | list-style-position: outside; 349 | list-style-image: none; 350 | } 351 | ol { 352 | margin-left: 1.45rem; 353 | margin-right: 0; 354 | margin-top: 0; 355 | padding-bottom: 0; 356 | padding-left: 0; 357 | padding-right: 0; 358 | padding-top: 0; 359 | margin-bottom: 1.45rem; 360 | list-style-position: outside; 361 | list-style-image: none; 362 | } 363 | dl { 364 | margin-left: 0; 365 | margin-right: 0; 366 | margin-top: 0; 367 | padding-bottom: 0; 368 | padding-left: 0; 369 | padding-right: 0; 370 | padding-top: 0; 371 | margin-bottom: 1.45rem; 372 | } 373 | dd { 374 | margin-left: 0; 375 | margin-right: 0; 376 | margin-top: 0; 377 | padding-bottom: 0; 378 | padding-left: 0; 379 | padding-right: 0; 380 | padding-top: 0; 381 | margin-bottom: 1.45rem; 382 | } 383 | p { 384 | margin-left: 0; 385 | margin-right: 0; 386 | margin-top: 0; 387 | padding-bottom: 0; 388 | padding-left: 0; 389 | padding-right: 0; 390 | padding-top: 0; 391 | margin-bottom: 1.45rem; 392 | } 393 | figure { 394 | margin-left: 0; 395 | margin-right: 0; 396 | margin-top: 0; 397 | padding-bottom: 0; 398 | padding-left: 0; 399 | padding-right: 0; 400 | padding-top: 0; 401 | margin-bottom: 1.45rem; 402 | } 403 | pre { 404 | margin-left: 0; 405 | margin-right: 0; 406 | margin-top: 0; 407 | padding-bottom: 0; 408 | padding-left: 0; 409 | padding-right: 0; 410 | padding-top: 0; 411 | margin-bottom: 1.45rem; 412 | font-size: 0.85rem; 413 | line-height: 1.42; 414 | background: hsla(0, 0%, 0%, 0.04); 415 | border-radius: 3px; 416 | overflow: auto; 417 | word-wrap: normal; 418 | padding: 1.45rem; 419 | } 420 | table { 421 | margin-left: 0; 422 | margin-right: 0; 423 | margin-top: 0; 424 | padding-bottom: 0; 425 | padding-left: 0; 426 | padding-right: 0; 427 | padding-top: 0; 428 | margin-bottom: 1.45rem; 429 | font-size: 1rem; 430 | line-height: 1.45rem; 431 | border-collapse: collapse; 432 | width: 100%; 433 | } 434 | fieldset { 435 | margin-left: 0; 436 | margin-right: 0; 437 | margin-top: 0; 438 | padding-bottom: 0; 439 | padding-left: 0; 440 | padding-right: 0; 441 | padding-top: 0; 442 | margin-bottom: 1.45rem; 443 | } 444 | blockquote { 445 | margin-left: 1.45rem; 446 | margin-right: 1.45rem; 447 | margin-top: 0; 448 | padding-bottom: 0; 449 | padding-left: 0; 450 | padding-right: 0; 451 | padding-top: 0; 452 | margin-bottom: 1.45rem; 453 | } 454 | form { 455 | margin-left: 0; 456 | margin-right: 0; 457 | margin-top: 0; 458 | padding-bottom: 0; 459 | padding-left: 0; 460 | padding-right: 0; 461 | padding-top: 0; 462 | margin-bottom: 1.45rem; 463 | } 464 | noscript { 465 | margin-left: 0; 466 | margin-right: 0; 467 | margin-top: 0; 468 | padding-bottom: 0; 469 | padding-left: 0; 470 | padding-right: 0; 471 | padding-top: 0; 472 | margin-bottom: 1.45rem; 473 | } 474 | iframe { 475 | margin-left: 0; 476 | margin-right: 0; 477 | margin-top: 0; 478 | padding-bottom: 0; 479 | padding-left: 0; 480 | padding-right: 0; 481 | padding-top: 0; 482 | margin-bottom: 1.45rem; 483 | } 484 | hr { 485 | margin-left: 0; 486 | margin-right: 0; 487 | margin-top: 0; 488 | padding-bottom: 0; 489 | padding-left: 0; 490 | padding-right: 0; 491 | padding-top: 0; 492 | margin-bottom: calc(1.45rem - 1px); 493 | background: hsla(0, 0%, 0%, 0.2); 494 | border: none; 495 | height: 1px; 496 | } 497 | address { 498 | margin-left: 0; 499 | margin-right: 0; 500 | margin-top: 0; 501 | padding-bottom: 0; 502 | padding-left: 0; 503 | padding-right: 0; 504 | padding-top: 0; 505 | margin-bottom: 1.45rem; 506 | } 507 | b { 508 | font-weight: bold; 509 | } 510 | strong { 511 | font-weight: bold; 512 | } 513 | dt { 514 | font-weight: bold; 515 | } 516 | th { 517 | font-weight: bold; 518 | } 519 | li { 520 | margin-bottom: calc(1.45rem / 2); 521 | } 522 | ol li { 523 | padding-left: 0; 524 | } 525 | ul li { 526 | padding-left: 0; 527 | } 528 | li > ol { 529 | margin-left: 1.45rem; 530 | margin-bottom: calc(1.45rem / 2); 531 | margin-top: calc(1.45rem / 2); 532 | } 533 | li > ul { 534 | margin-left: 1.45rem; 535 | margin-bottom: calc(1.45rem / 2); 536 | margin-top: calc(1.45rem / 2); 537 | } 538 | blockquote *:last-child { 539 | margin-bottom: 0; 540 | } 541 | li *:last-child { 542 | margin-bottom: 0; 543 | } 544 | p *:last-child { 545 | margin-bottom: 0; 546 | } 547 | li > p { 548 | margin-bottom: calc(1.45rem / 2); 549 | } 550 | code { 551 | font-size: 0.85rem; 552 | line-height: 1.45rem; 553 | } 554 | kbd { 555 | font-size: 0.85rem; 556 | line-height: 1.45rem; 557 | } 558 | samp { 559 | font-size: 0.85rem; 560 | line-height: 1.45rem; 561 | } 562 | abbr { 563 | border-bottom: 1px dotted hsla(0, 0%, 0%, 0.5); 564 | cursor: help; 565 | } 566 | acronym { 567 | border-bottom: 1px dotted hsla(0, 0%, 0%, 0.5); 568 | cursor: help; 569 | } 570 | abbr[title] { 571 | border-bottom: 1px dotted hsla(0, 0%, 0%, 0.5); 572 | cursor: help; 573 | text-decoration: none; 574 | } 575 | thead { 576 | text-align: left; 577 | } 578 | td, 579 | th { 580 | text-align: left; 581 | border-bottom: 1px solid hsla(0, 0%, 0%, 0.12); 582 | font-feature-settings: 'tnum'; 583 | -moz-font-feature-settings: 'tnum'; 584 | -ms-font-feature-settings: 'tnum'; 585 | -webkit-font-feature-settings: 'tnum'; 586 | padding-left: 0.96667rem; 587 | padding-right: 0.96667rem; 588 | padding-top: 0.725rem; 589 | padding-bottom: calc(0.725rem - 1px); 590 | } 591 | th:first-child, 592 | td:first-child { 593 | padding-left: 0; 594 | } 595 | th:last-child, 596 | td:last-child { 597 | padding-right: 0; 598 | } 599 | tt, 600 | code { 601 | background-color: hsla(0, 0%, 0%, 0.04); 602 | border-radius: 3px; 603 | font-family: 'SFMono-Regular', Consolas, 'Roboto Mono', 'Droid Sans Mono', 604 | 'Liberation Mono', Menlo, Courier, monospace; 605 | padding: 0; 606 | padding-top: 0.2em; 607 | padding-bottom: 0.2em; 608 | } 609 | pre code { 610 | background: none; 611 | line-height: 1.42; 612 | } 613 | code:before, 614 | code:after, 615 | tt:before, 616 | tt:after { 617 | letter-spacing: -0.2em; 618 | content: ' '; 619 | } 620 | pre code:before, 621 | pre code:after, 622 | pre tt:before, 623 | pre tt:after { 624 | content: ''; 625 | } 626 | @media only screen and (max-width: 480px) { 627 | html { 628 | font-size: 100%; 629 | } 630 | } 631 | --------------------------------------------------------------------------------