decaporg / decap-cms

A Git-based CMS for Static Site Generators
https://decapcms.org
MIT License
17.83k stars 3.04k forks source link

API Overhaul: Preview Templating #1041

Open erquhart opened 6 years ago

erquhart commented 6 years ago

Let's make custom preview templating better.

This issue outlines a potential approach for improving the custom preview authoring experience through documentation driven development. Rough documentation will be drawn up right in the comments describing an ideal custom preview interface, and POC's (proof of concept) validating these proposals can be linked as we go.

Quick Links:

Current

Currently, registering a preview template looks like this:

var PostPreview = createClass({
  render: function() {
    var entry = this.props.entry;
    var image = entry.getIn(['data', 'image']);
    var bg = image && this.props.getAsset(image);
    return h('div', {},
      h('div', {className: "cover"},
        h('h1', {}, entry.getIn(['data', 'title'])),
        bg ? h('img', {src: bg.toString()}) : null
      ),
      h('p', {},
        h('small', {}, "Written " + entry.getIn(['data', 'date']))
      ),
      h('div', {"className": "text"}, this.props.widgetFor('body'))
    );
  }
});

It's a generally effective templating system, but with three primary drawbacks:

Proposed

Ideally, developers could provide a simple mustache/handlebars template - here's the above example rewritten in handlebars:

var postPreviewTemplate = `
  <div>
    <div class="cover">
      <h1>{{title}}</h1>
      {{#image}}
        <img src="{{getAsset image}}"/>
      {{/image}}
    </div>
    <p>
      <small>Written {{date}}</small>
    </p>
    <div class="text">
      {{widgetFor "body"}}
    </div>
  </div>
`;

This style of templating should prove far more approachable, simpler for troubleshooting, and clearer in purpose. Handlebars helper functions would be created to apply necessary functionality, for example, our use of getAsset in the template above.

widgetFor / widgetsFor

As mentioned before, the widgetFor / widgetsFor methods for getting values from shallow and deep fields, respectively, are confusing to use. widgetFor accepts a field name and returns a React component with the field's value wrapped in the preview component, ready for rendering. widgetsFor accepts an array of keys for accessing a nested field, but instead of returning a React component, it returns an object with keys "widget" and "data", for accessing the component or just the raw value respectively.

Instead, widgetFor should handle both jobs, accepting either a field name string, or else an array of nested field names for deep retrieval. It should always return a React component, as raw values, including nested ones, are already available on the entry object.

Current

An implementation from the example project that uses both widgetFor and widgetsFor:

var GeneralPreview = createClass({
  render: function() {
    var entry = this.props.entry;
    var title = entry.getIn(['data', 'site_title']);
    var posts = entry.getIn(['data', 'posts']);
    var thumb = posts && posts.get('thumb');

    return h('div', {},
      h('h1', {}, title),
      h('dl', {},
        h('dt', {}, 'Posts on Frontpage'),
        h('dd', {}, this.props.widgetFor(['posts', 'front_limit']) || 0),

        h('dt', {}, 'Default Author'),
        h('dd', {}, this.props.widgetsFor('posts').getIn(['data', 'author']) || 'None'),

        h('dt', {}, 'Default Thumbnail'),
        h('dd', {}, thumb && h('img', {src: this.props.getAsset(thumb).toString()}))
      )
    );
  }
});

Proposed

Here's what the above template would look like with the proposed removal of widgetsFor:

var generalPreviewTemplate = `
  <div>
    <h1>{{ site_title }}</h1>
    <dl>
      {{#posts}}
        <dt>Posts on Frontpage</dt>
        <dd>{{widgetFor "front_limit"}}</dd>

        <dt>Default Author</dt>
        <dd>{{author}}</dd>

        <dt>Default Thumbnail</dt>
        <dd>
          {{#thumb}}<img src="{{getAsset thumb}}"/>{{/thumb}}
      {{/posts}}
    </dl>
  </div>
`;

Proof of Concept

A branch can be referenced here: https://github.com/netlify/netlify-cms/compare/api-register-preview-template

Deploy preview here: https://api-register-preview-template--cms-demo.netlify.com

The first example above is working in the POC - the preview for Post entries is created using a handlebars template in example/index.html. The second example has not yet been implemented.

Documentation

Customizing the Preview Pane

The preview pane shows raw, roughly formatted content by default, but you can register templates and styles so that the preview matches what will appear when the content is published. Netlify CMS has a few options for doing this.

registerPreviewTemplate

The registerPreviewTemplate registry method accepts a name, a template string, a data provider function, and an optional template parser name.

param required type default description
name yes string n/a Used to reference the template in configuration
template yes React component or string n/a The raw template
dataProvider - function n/a Accepts raw entry data and returns prepared template data
parserName - string "handlebars" if template is a string, otherwise "" The name of a registered template parser

Each example below, given a title field value of "My First Post", will output:

<h1>My First Post</h1>

Example using Handlebars

Netlify CMS ships with a Handlebars template parser that is registered and used by default for any string templates.

/**
 * With ES6 + modules and Webpack
 * Use [raw-loader](https://github.com/webpack-contrib/raw-loader) to import template text via Webpack.
 */
import { registerPreviewTemplate } from 'netlify-cms'
import postTemplate from './post-template.hbs' // handlebars template, contains "<h1>{{title}}</h1>"

registerPreviewTemplate("post", postTemplate)

/**
 * With ES5
 * Use `CMS` global to access registry methods.
 */
var postTemplate = "<h1>{{title}}</h1>"
CMS.registerPreviewTemplate("post", postTemplate)

Example using a React Component

Template parsers output a React component which the CMS uses directly, but you can also bypass templating and create the React component yourself for tighter control.

Note: field values are accessed by the template component via the raw entry prop, which is an Immutable.js Map, where each field value is stored by name under the data property. For example, accessing the title field value on the entry prop looks like: entry.getIn(['data', 'title']).

/**
 * With ES6 + modules, JSX, and Webpack
 */
import React from 'react'
import { registerPreviewTemplate } from 'netlify-cms'

export class PostTemplate extends React.Component {
  render() {
    return <h1>{this.props.entry.getIn(['data', 'title'])}</h1>
  }
}

registerPreviewTemplate("post", PostTemplate)

/**
 * With ES5
 * Use `CMS` global to access registry methods.
 * Use `createClass` global to access [create-react-class](https://www.npmjs.com/package/create-react-class).
 * Use `h` global to access [React.createElement](https://reactjs.org/docs/react-api.html#createelement).
 */
var PostTemplate = createClass({
  render: function() {
    return h('h1', {}, this.props.entry.getIn(['data', 'title']))
  }
})

CMS.registerPreviewTemplate("post", PostTemplate)

Example using a custom data provider

When reusing a production template, the data object expected by the template will often be different from the one passed into the template parser by Netlify CMS. To address this, you can pass in a data provider function that receives the data object provided by Netlify CMS and returns an object that will work with your template. The received value will be an Immutable Map, and the function must return an Immutable Map.

Note that the data provider function doesn't receive all of the props that are passed to the template parser, just the data prop, which contains the entry values.

/**
 * With ES6 + modules and Webpack
 * Use [raw-loader](https://github.com/webpack-contrib/raw-loader) to import template text via Webpack.
 */
import { registerPreviewTemplate } from 'netlify-cms'
import postTemplate from './post-template.hbs' // handlebars template, contains "<h1>{{post.title}}</h1>"

const providePostData = data => data.setIn(['post', 'title'], data.get('title'))

registerPreviewTemplate("post", postTemplate, providePostData)

/**
 * With ES5
 * Use `CMS` global to access registry methods.
 * Uses an inline template since site templates shouldn't be available in production.
 */
var postTemplate = "<h1>{{post.title}}</h1>"

var providePostData = function(data) {
  return data.setIn(['post', 'title'], data.get('title'))
}

CMS.registerPreviewTemplate("post", postTemplate, providePostData)

registerTemplateParser

The registerTemplateParser registry method accepts a name and a parsing function.

param required type default description
name yes string n/a Used to reference the parser when registering templates
parser - function n/a Accepts a string template, template data, and options hash; returns HTML
options - object {} Passed through to parser function

Example

We'll create a simplified EJS template parser to demonstrate. Note that a real parser would need to provide a few helpers for a complete integration (docs TBA).

// ES6
import ejs from 'ejs'
import { registerTemplateParser } from 'netlify-cms'

const ejsParser = (template, data, opts) => {
  return ejs.render(template, data, opts)
}

registerTemplateParser('ejs', ejsParser)

Given template <h1><%= title %><h1> and data { title: 'My First Post' }, this parser would output: <h1>My First Post<h1>.

biilmann commented 6 years ago

Great stuff!

I agree with getting rid of the widgetFor and widgetsFor distinction, always felt a bit clunky. I feel a bit the same way around the current metadata construct used when accessing relations content in a custom preview template.

The old ember-prototype of the CMS used handlebars since that's native to ember, and for simple preview templates that's a lot more straight forward to quickly put together than React components. However, I do think the underlying React component concept is more robust and should always be an option.

My suggestion would be that we make a template language abstraction and implement handlebars (or mustache or something with a similar feel) as a built in template language. The abstraction would be something like:

class TemplateLanguage {
   compile(template : String) {
       return compileTemplateIntoReactComponent(template)
   }
}

Another nicety for the Ember version was loading templates from script tags. This made it really easy to get the CMS going just by pulling in the cdn lib and then adding some preview templates in the index.html like:


<script type="text/x-handlebars-template" name="post">
<div>
  <h1>{{ entry.title }}</h1>

   <div class="text">{{ widgetFor "body" }}</div>
</div>
</script>
binaryben commented 6 years ago

YES PLEASE! This looks much simpler. My main comment on this is that it needs to handle some basic if/then and loop (for lists) logic for some pages if the proposed handlebars option doesn't already include that.

I'm also wondering if there is any way to re-use existing templates and partials used by popular static site generators? It kind of sucks a bit to have to write them for use on the website, and then have to rewrite/rework them again just to preview in the CMS portal.

erquhart commented 6 years ago

Crystal clear: template reuse is the name of the game. I'm thinking we'll add registerTemplateParser to allow custom parsers. True template reuse is going to be challenging, but I don't think it's impossible. Even Go templates can be parsed in the browser via Gopherjs.

The challenge is working with a template's data structure, which may be much different from what we have in Netlify CMS. Perhaps a bit of extensible mapping with transforms could stand in the gap. Need to try some of this out with a real project and I'll update the OP with some more concepts from there.

erquhart commented 6 years ago

Alrighty, documentation rough draft complete, thoughts welcome. I'll get some more POC work added tomorrow. Considering the value of this documentation, I'm considering doing these API overhaul tickets partially in a PR so things are versioned.

tech4him1 commented 6 years ago

@erquhart @biilmann Are we planning on "compiling" the templates into React components, then running them? Or just using them as templates directly, with their own engine?

erquhart commented 6 years ago

Compiling to React - there's a working handlebars implementation in the POC: https://github.com/netlify/netlify-cms/compare/api-register-preview-template

tech4him1 commented 6 years ago

Ah, but you're still compiling to React after you run it though Handlebars, if I understand correctly. I was thinking you were compiling to react first, with all variables intact, then parsing them in React.

erquhart commented 6 years ago

That's be awesome, but we'd need a lib that does that for each template language - surprisingly, there isn't even one for handlebars, I checked before starting.

erquhart commented 6 years ago

Since every parser is unique, we'll need to allow some kind of pass through, like an opts object maybe (but not really options), so that the parser can accept helper registration, partials, whatever it may want to expose.

Going to look at the abstraction layer concept now.

haileypate commented 6 years ago

Since I use jinja2 to process my templates and generate site files, I'd love to be able to point netlifyCMS to a directory in which I've prepared templates that could get gobbled up and leveraged by the EditorPreviewPane component.

I figured I'd be doing double work to recreate templates for use in netlifyCMS's preview feature for the forseeable future... but... I'd totally invest in time in leveraging these new options

Do y'all think a node package like jinja-to-js might be useful to someone like me in the above workflow?

Just a quick yes/no/maybe answer would help me know if I should do more learning/research =)

erquhart commented 6 years ago

Let me get further in the docs, I'm pushing up a guide to creating template compilers next, which is what you'd need to do to support Jinja templates.

Sent with GitHawk

erquhart commented 6 years ago

@haileypate at a glance I'd say it's definitely possible. You'd commit the js output and write a template compiler that passes the data object to the template function. Check out the template compiler guide linked here: https://github.com/netlify/netlify-cms/pull/1311

mischkl commented 5 years ago

Is this still on track to be implemented / further explored? I would love to be able to for instance reuse Go templates from Hugo in the NetlifyCMS preview pane without having to rewrite them in React. Obviously I don't expect the NetlifyCMS team to take care of all the work involved in making this possible, but just offering a pluggable templating system would be the first step.

That being said, I suppose there's nothing stopping someone from coming up with a preview template that does essentially what I mention while utilizing the current API.

erquhart commented 5 years ago

Here's the reference for Hugo/Go templates in the browser: https://github.com/gohugoio/hugo/issues/4543

Still a ways to go on that, I was overly optimistic when I authored this issue.

stale[bot] commented 4 years ago

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.

ValeriyTen commented 4 years ago

Here's the reference for Hugo/Go templates in the browser: gohugoio/hugo#4543

Still a ways to go on that, I was overly optimistic when I authored this issue.

@erquhart is there any progress on that? I configured NetlifyCMS with Hugo that have markdown files on self-hosted Gitlab. Now I need to make NetlifyCMS preview pane to display data in my Hugo theme style and to render templates and shortcodes correctly.