Skip to content

Code driven CMS powered by GraphQL & React.

License

Notifications You must be signed in to change notification settings

smooth-code/smooth.js

Repository files navigation

Smooth.js

Code driven CMS powered by GraphQL & React.

NPM version CircleCI Join the community on Spectrum

How to use

Setup

Install it:

npm install --save smooth react react-dom

and add a script to your package.json like this:

{
  "scripts": {
    "dev": "smooth dev",
    "build": "smooth build",
    "start": "smooth start"
  }
}

After that, the file-system is the main API. Every .js file becomes a route that gets automatically processed and rendered.

Populate ./pages/index$.js inside your project:

export default () => <div>Welcome to smooth.js!</div>

and then just run npm run dev and go to http://localhost:3000. To use another port, you can run npm run dev -- -p <your port here>.

So far, we get:

  • Automatic transpilation and bundling (with webpack and babel)
  • Hot code reloading
  • Server rendering and indexing of ./pages
  • Static file serving. ./static/ is mapped to /static/ (given you create a ./static/ directory inside your project)

Structure

Directives

@content

Can be used on a GraphQL type definition. Indicates that this type will be available in backoffice.

# A "Book" content will be available in backoffice
type Book @content {
  title: String @field
}

# This type will not be available in backoffice
type User {
  name: String
}

Specify icon

Some backoffices like Wordpress supports an icon. @content supports it as a parameter:

type Book @content(icon: "dashicons-format-gallery") {
  title: String @field
}

Specify slug

Slug is automatically generated from the name but you can also specify a specific one. Slug has an impact in the API and in the backoffice. It is the internal name of the content.

type Book @content(slug: "awesome-book") {
  title: String @field
}

Specify label

Label is automatically generated from the name but you can also specify a specific one. Label has an impact in the backoffice. It is the name used to display the name of the content.

type Book @content(label: "Awesome Book") {
  title: String @field
}

Specify a description

GraphQL comments are automatically converted in description in the backoffice.

"It is just a book, relax."
type Book @content {
  title: String @field
}
@field

Can be used on a GraphQL field definition. Indicates that this field will be available in the backoffice. @field must only be used in a type marked as @content.

type Book @content {
  # This field is editable in backoffice
  title: String @field
  # This field is not editable in backoffice
  # you have to write a custom resolver for it
  likes: Int
}

Specify type explicitely

Most of field types are inferred from the GraphQL type of the field. A String will generate a text input, a Boolean will display a checkbox, etc... Sometimes you have to precise the exact type of field. For an example, a String display a shortText, but you could also want a richText.

For this specific use-case you can specify a type argument in the @field directive:

type Book @content {
  # Will display a "shortText" in backoffice
  title: String @field
  # Will display a "longText" in backoffice
  description: String @field(type: longText)
}

Available types are: shortText, longText and richText, all other types are inferred from the GraphQL types, including special ones like Image, Link and Media.

Specify a label

Exactly like for @content you may want to display a custom label for this field. The label is used only in the backoffice.

type Book @content {
  title: String @field(label: "My awesome title")
}

Specify a description

GraphQL comments are automatically converted in description in the backoffice.

type Book @content {
  "The title of the book, yeah the big title!"
  title: String @field
}
@block

Can be used on a GraphQL type definition. Indicates that this type will be available in the special Block type.

type Page @content {
  # The special type "Block" indicates that all blocks will be available in backoffice
  blocks: [Block] @field
}

# This type will be available as a block in the "Page" content
type Hero @block {
  text: String @field
}

Additional GraphQL types

Date

RFC 3339 compliant date. See graphql-iso-date for more information.

DateTime

RFC 3339 compliant date time. See graphql-iso-date for more information.

Image

Represents an image, with several pre-configured sizes.

type ImageSize {
  width: Int!
  height: Int!
  url: String!
}

type Image {
  id: ID!
  url: String!
  mimeType: String!
  alt: String
  title: String
  thumbnail: ImageSize!
  medium: ImageSize!
  large: ImageSize!
}
Media

Represents a media, just a file.

type Media {
  title: String
  url: String!
}
Link

Represents a link to another page or content.

type Link {
  title: String
  url: String!
  target: String!
}
Metadata

Additional metadata accessible on contents.

type Metadata {
  id: ID!
  slug: String!
}
Block

Special type to indicates all defined blocks.

Schemas

All your GraphQL schemas must be placed in src/schemas, you can place them in separated files or in a single files. The only requirement is to place them in src/schemas.

You must specify your type definition in a named export typeDefs:

import gql from 'graphql-tag'

export const typeDefs = gql`
  type Actor {
    name: String! @field
  }

  type Movie @content {
    title: String! @field
    description: String! @field(type: richText)
    cover: Image! @field
    actors: [Actor!]! @field
  }
`

And you can specify resolvers in a named export resolvers:

import gql from 'graphql-tag'

export const typeDefs = gql`
  type Movie @content {
    title: String! @field
    likes: Int
  }
`

export const resolvers = {
  Movie: {
    async likes(object, params, { slug }) {
      // This is an example of an external call to get movie likes
      return api.getMovieLikes(slug)
    },
  },
}

resolvers are only required for field definition not marked as @field.

Content API

Sometimes, you may want to be able to request a set of contents. For example, a block that display three movies automatically.

import gql from 'graphql-tag'

export const typeDefs = gql`
  type MovieListBlock @block {
    # This type is not displayed in backoffice
    movies: [Movie!]!
  }
`
export const resolvers = {
  MovieListBlock: {
    // This resolvers automatically display a list of movies
    async movies(object, params, { api }) {
      // The "type" is the slug of your content
      return api.getContents({ type: 'movie' })
    },
  },
}
Filters

Available filters depends of your backoffice, using Wordpress we rely on wp-rest-filter. To use it, add a query to api.getContents:

// Get last 30 news ordered by "publicationDate"
api.getContents({
  type: 'news',
  query: {
    per_page: 30,
    meta_key: 'publicationDate',
    orderby: 'meta_value',
    order: 'desc',
  },
})

// Get the last 4 projects:
// - ordered by "date"
// - with "hasPage" flag marked as true
// - and exclude the current object
api.getContents({
  type: 'projects',
  query: {
    per_page: 4,
    orderby: 'date',
    order: 'desc',
    filter: {
      meta_query: [{ key: 'hasPage', value: 1 }],
    },
    exclude: [object.id],
  },
})

Pages

All your pages must be placed in src/pages. The name of the page determines the route of the page. Pages offers several possibilities, display a content or create a page from scratch.

All pages must have a default export that represents the component used to display the page.

// src/pages/hello.js
export default function Hello() {
  return 'Hello world!'
}

When I access to /hello, I see "Hello world!".

Content Pages

A content page is a page with a named export contentFragment that contains a fragment on a GraphQL type marked as @content. And of course a component as the default export. All fields specified in fragments are available as props.

import React from 'react'
import gql from 'graphql-tag'

// The name of the fragment "MovieProps" does not matter
// but it is recommended to named it like that.
export const contentFragment = gql`
  fragment MovieProps on Movie {
    title
    description
    cover {
      url
      alt
    }
  }
`

export default function Movie({ title, description, cover }) {
  return (
    <div>
      <img src={cover.url} alt={cover.alt} />
      <div>{title}</div>
      <p>{description</p>
    </div>
  )
}

Fixed slug pages

All pages are "wildcard" pages by default. It means that the page matches for all urls. For example, if I create a page books.js. The page will matches for URL "/books/foo/bar". In fact if this page has a content, it will look for a content with the slug "foo/bar". Most of time this is the correct behaviour, but sometimes you may want to be able to control it and to create a dedicated page.

To create a dedicated page, not wildcard, you have to add a $ at the end of the name. Let's take the same page books$.js. The page will matches only URL "/books/foo/bar" but it will only look for the content with the slug "books".

Customize slug

To change the slug looked for by a fixed slug page, you can use contentSlug variable. For example, a page named best-book$.js will look for best-book by default. But you can customize it.

// The page is still accessible under "/best-book", but it will look for "harry-potter" book
export const contentSlug = 'harry-potter'

You can also specify a function to compute slug from the url.

export const contentSlug = ({ location }) => location.pathname

Query

You can write your GraphQL queries using the Query component. For example, in _app.js you can choose to display the settings.

// src/_app.js
import React from 'react'
import gql from 'graphql-tag'
import { Query } from 'smooth/query'

const PAGE = gql`
  query Settings {
    settings(slug: "main") {
      title
    }
  }
`

export default function Page({ Component, ...props }) {
  return (
    <Query query={PAGE}>
      {({ data }) =>
        data && (
          <>
            <h1>{data.settings.title}</h1>
            <Component {...props} />
          </>
        )
      }
    </Query>
  )
}

Content

Link to another content

To link a content, you have to know two things: the slug and the name of the content page.

You can find the content slug in metadata and the name of the content page is just the name of the page file.

The Link component take care of the language for you, you can safely use it to create a link to another page.

import React from 'react'
import gql from 'graphql-tag'
import { Link } from 'smooth/router'

export const contentFragment = gql`
  fragment PageProps on Page {
    books {
      metadata {
        id
        slug
      }
      name
    }
  }
`

export default function Page({ books }) {
  return (
    <ul>
      {allBooks.map(book => (
        <li key={book.metadata.id}>
          <Link to={`/books/${book.metadata.slug}`}>{book.name}</Link>
        </li>
      ))}
    </ul>
  )
}

Automatic code splitting

Every import you declare gets bundled and served with each page. That means pages never load unnecessary code!

import cowsay from 'cowsay-browser'

export default () => <pre>{cowsay.say({ text: 'hi there!' })}</pre>

CSS

Built-in CSS support

Smooth.js doesn't provides a built-in CSS in JS solution. But emotion is the most easy solution, because it doesn't require any SSR configuration. You can install it and use in the project.

Examples

/** @jsx jsx */
import { jsx, css } from '@emotion/core'

export default () => (
  <div
    css={css`
      font: 15px Helvetica, Arial, sans-serif;
      background: #eee;
      padding: 100px;
      text-align: center;
      transition: 100ms ease-in background;

      &:hover {
        background: #ccc;
      }
    `}
  >
    <p>Hello World</p>
  </div>
)

Please see the emotion documentation for more examples.

CSS-in-JS

Examples

It's possible to use any existing CSS-in-JS solution. The simplest one is inline styles:

export default () => <p style={{ color: 'red' }}>hi there</p>

Importing CSS / Sass / Less / Stylus files

Importing .css, .scss, .less or .styl files is not yet supported. You can probably achieve it by adding some webpack configuration but it is not recommended. CSS in JS is much more powerful with SSR rendering.

Static file serving (e.g.: images)

Create a folder called static in your project root directory. From your code you can then reference those files with /static/ URLs:

export default () => <img src="/static/my-image.png" alt="my image" />

Note: Don't name the static directory anything else. The name is required and is the only directory that Smooth.js uses for serving static assets.

Routing

Examples

Client-side transitions between routes can be enabled via a <Link> component. Consider these two pages:

// pages/index$.js
import { Link } from 'smooth/router'

export default () => (
  <div>
    Click <Link to="/about">About</Link> to read more
  </div>
)
// pages/about$.js
export default () => <p>Welcome to About!</p>

Note: "smooth/router" exposes all methods from "react-router-dom".

Dynamic Import

Examples

Smooth.js supports TC39 dynamic import proposal for JavaScript. With that, you could import JavaScript modules (inc. React Components) dynamically and work with them.

You can think dynamic imports as another way to split your code into manageable chunks. Since Smooth.js supports dynamic imports with SSR, you could do amazing things with it.

Here are a few ways to use dynamic imports.

1. Basic Usage (Also does SSR)

import loadable from 'smooth/loadable'

const DynamicComponent = loadable(() => import('../components/hello'))

export default () => (
  <div>
    <Header />
    <DynamicComponent />
    <p>HOME PAGE is here!</p>
  </div>
)

2. With Custom Loading Component

import loadable from 'smooth/loadable'

const DynamicComponentWithCustomLoading = loadable(
  () => import('../components/hello2'),
  { fallback: <p>...</p> },
)

export default () => (
  <div>
    <Header />
    <DynamicComponentWithCustomLoading />
    <p>HOME PAGE is here!</p>
  </div>
)

Note: "smooth/loadable" exposes all methods from "@loadable/component".

Custom <App>

Examples

Smooth.js uses the App component to initialize pages. You can override it and control the page initialization. Which allows you to do amazing things like:

  • Persisting layout between page changes
  • Keeping state when navigating pages

To override, create the ./src/_app.js file and override the App class as shown below:

import React from 'react'

export default ({ Component, ...props }) => (
  <div className="layout">
    <Component {...props} />
  </div>
)

Custom error handling

404 or 500 errors are handled both client and server side by a default component error.js. If you wish to override it, define a _error.js in the src folder:

⚠️ The default error.js component is only used in production ⚠️

import React from 'react'

export default ({ error }) => (
  <p>
    {error.statusCode
      ? `An error ${error.statusCode} occurred on server`
      : 'An error occurred on client'}
  </p>
)

Custom configuration

For custom advanced behavior of Smooth.js, you can create a smooth.config.js in the root of your project directory (next to src/ and package.json).

Note: smooth.config.js is a regular Node.js module, not a JSON file. It gets used by the Smooth server and build phases, and not included in the browser build.

// smooth.config.js
module.exports = {
  /* config options here */
}

Customizing webpack config

Examples

In order to extend our usage of webpack, you can define a function that extends its config via smooth.config.js.

// smooth.config.js is not transformed by Babel. So you can only use javascript features supported by your version of Node.js.

module.exports = {
  webpack: (config, { dev, isServer, defaultLoaders }) => {
    // Perform customizations to webpack config
    // Important: return the modified config
    return config
  },
  webpackDevMiddleware: config => {
    // Perform customizations to webpack dev middleware config
    // Important: return the modified config
    return config
  },
}

The second argument to webpack is an object containing properties useful when customizing its configuration:

  • dev - Boolean shows if the compilation is done in development mode
  • isServer - Boolean shows if the resulting configuration will be used for server side (true), or client size compilation (false).
  • defaultLoaders - Object Holds loader objects Smooth.js uses internally, so that you can use them in custom configuration
    • babel - Object the babel-loader configuration for Smooth.js.

Example usage of defaultLoaders.babel:

// Example smooth.config.js for adding a loader that depends on babel-loader
module.exports = {
  webpack: (config, {}) => {
    config.module.rules.push({
      test: /\.mdx/,
      use: [
        options.defaultLoaders.babel,
        {
          loader: '@mdx-js/loader',
          options: pluginOptions.options,
        },
      ],
    })

    return config
  },
}

Customizing babel config

Examples

In order to extend our usage of babel, you can simply define a .babelrc file at the root of your app. This file is optional.

If found, we're going to consider it the source of truth, therefore it needs to define what smooth needs as well, which is the smooth/babel preset.

This is designed so that you are not surprised by modifications we could make to the babel configurations.

Here's an example .babelrc file:

{
  "presets": ["smooth/babel"],
  "plugins": []
}

The smooth/babel preset includes everything needed to transpile React applications. This includes:

  • preset-env
  • preset-react
  • plugin-proposal-class-properties
  • @loadable/babel-plugin

These presets / plugins should not be added to your custom .babelrc. Instead, you can configure them on the smooth/babel preset:

{
  "presets": [
    [
      "smooth/babel",
      {
        "preset-env": {},
        "transform-runtime": {}
      }
    ]
  ],
  "plugins": []
}

The modules option on "preset-env" should be kept to false otherwise webpack code splitting is disabled.

Plugins Hooks

Plugins API

  • resolveOptions

smooth-node.js

  • onCreateServer
  • onCreateApolloServerConfig
  • onRenderBody
  • onServerError
  • onCreateBabelConfig
  • onCreateWebpackConfig
  • getContents
  • getContent
  • onBuild
  • wrapRootElement

smooth-browser.js

  • onRouteUpdate
  • onSelectContentFields
  • wrapContentElement

Production deployment

To deploy, instead of running smooth, you want to build for production usage ahead of time. Therefore, building and starting are separate commands:

smooth build
smooth start

For example, to deploy with now a package.json like follows is recommended:

{
  "name": "my-app",
  "dependencies": {
    "smooth": "latest"
  },
  "scripts": {
    "dev": "smooth dev",
    "build": "smooth build",
    "start": "smooth start"
  }
}

Then run now and enjoy!

Browser support

Smooth.js supports IE11 and all modern browsers out of the box using @babel/preset-env.

Contributing

Please see our contributing.md

Authors