decaporg / decap-cms

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

Hard to use with CSS-in-JS libs. #793

Open whmountains opened 6 years ago

whmountains commented 6 years ago

Background

I'm currently using Netlify CMS to build a website for a client. It's a completely static site, but it uses React as a template engine. I also use the React bundle to render a preview inside Netlify CMS.

The other important detail is that this website is styled with styled-components, which works by injecting css rules into document.getElementsByTagName('head')[0].

The Problem

Netlify CMS transplants the preview component inside an iframe, while the <style> elements generated by styled-components remain outside the iframe. This leaves me with an unstyled preview, which is most unappealing. ✨

I haven't tested, but I expect this to affect other CSS-in-JS libraries like Glamorous, jsxstyle, or JSS as well as any other React library that injects extra elements, like react-helmet or react-portal.

erquhart commented 6 years ago

It's not always a given that a site's CSS will automatically apply to the preview pane - doing so often requires porting the styles in via registerPreviewStyle. Does styled-components provide any way to output a CSS file?

erquhart commented 6 years ago

Hmm looks like styled components adds a data attribute to its style elements - I'll bet the others do the same. The registerPreviewStyle registry accepts file paths only, but it could also accept a CSS selector for this use case, which we could run with querySelectorAll and copy matching elements into the preview pane. We should also accept raw styles while we're on the subject.

That said, we need to give some higher level consideration to the proper abstraction for these API changes. What do you think?

ghost commented 6 years ago

I had the same problem with CSS-Modules on GatsbyJS, I hope this helps:

according to the documentation style-loader is able to inject the inline-CSS into an iframe.

But in the end I was unable to set up this functionality with netlify-cms and used the Extract-Text-Plugin This extracts every CSS from all components into a new stylesheet which i include with CMS.registerPreviewStyle('stylesCMS.css')

The relevant parts from my webpack.config look like this:

const ExtractTextPlugin = require("extract-text-webpack-plugin");
const extractLess = new ExtractTextPlugin({
    filename: "stylesCMS.css",
    // path: path.resolve(__dirname, '../static/admin/')
});

.... 
  module: {
    rules: [
  {
         test: /\.css$/,
         use: extractLess.extract({
           use: [
           {
               loader: "css-loader?modules&importLoaders=1"
           }],
         })
whmountains commented 6 years ago

Nice trick @zionis137.

I'm still hoping for some more official support, but this is a nice workaround!

whmountains commented 6 years ago

@erquhart Your proposal for finding CSS by selector sounds reasonable.

Another possibility would be to allow loading the preview iframe via a URL rather than trying to inject a react tree inside. I think it might be a more surefire solution than trying to identify the misplaced CSS and teleport it somewhere else.

erquhart commented 6 years ago

@whmountains we need some pretty tight, realtime control over that preview pane, so far it seems injecting the React tree is a requirement - the preview isn't served separately. How would you propose doing this with a URL?

tizzle commented 6 years ago

Hey,

i'm wondering if there is any news on this, as i ran into the exact same issue as @whmountains. In addition i feel that using extract-text-webpack-plugin is not going to work, as in my understanding this won't pick up the styled-components definitions. This is discussed here, here and here in a little more detail.

Maybe rendering the preview into a React portal instead of an iFrame would solve the issue?

erquhart commented 6 years ago

That's an interesting idea. I haven't looked into portals at all, but feel free to check it out and see if it's possible.

whmountains commented 6 years ago

@erquhart To answer your question I would create a subscriber interface, which a HoC within the preview pane can access. Similar to how react-redux works with connect wrapping the store.subscribe api. In fact, I would copy the react-redux api as much as possible since it's performant and everyone knows how to use it.

You could also have another HoC which would wrap the entire preview pane and implement scroll sync by listening to scroll events.

whmountains commented 6 years ago

AFAIK netlify-cms uses redux under the hood. Could you just expose the store inside the iframe?

Everything from EditorPreviewPane on down would be incorporated into the custom build running inside the iframe.

Just throwing out ideas. I'm not very familiar with the codebase or all the caveats a system like this would introduce. It just seems that netlify-cms's preview interface is breaking core assumptions about how web pages are rendered and it would be nice to fix that somehow so everything "just works".

robertgonzales commented 6 years ago

For anyone using emotion, I solved this issue by using SSR on the fly to extract the css and then inject it into the nearest document (iframe) head. Very hacky but it works.

import { renderToString } from "react-dom/server"
import { renderStylesToString } from "emotion-server"

class CSSInjector extends React.Component {
  render() {
    return (
      <div
        ref={ref => {
          if (ref && !this.css) {
            this.css = renderStylesToString(renderToString(this.props.children))
            ref.ownerDocument.head.innerHTML += this.css
          }
        }}>
        {React.Children.only(this.props.children)}
      </div>
    )
  }
}

It works by wrapping your preview template:


CMS.registerPreviewTemplate("blog", props => (
  <CSSInjector>
    <BlogPreviewTemplate {...props} />
  </CSSInjector>
))
erquhart commented 6 years ago

That will be much less hacky once #1162 lands. Any library that can export strings will be covered at that point.

Anyone up for talking @mxstbr into exporting strings from styled components?

erquhart commented 6 years ago

On second thought that PR won't help for the emotion case at all. I'm also thinking that what you've done really isn't hacky, especially considering how you moved it into a component. This might even find it's way into the docs! 😂

mxstbr commented 6 years ago

styled-components has a way to target an iframe as it's injection point:


import { StyleSheetManager } from 'styled-components'

<StyleSheetManager target={iframeHeadElem}>
  <App />
</StyleSheetManager>

Any styled component within App will now inject it's style tags into the target elem! Maybe that's helpful?

erquhart commented 6 years ago

I was just looking at StyleSheetManager recently and wondering if it might work for this - looks like it should!

@whmountains care to give it a go and let us know?

mxstbr commented 6 years ago

Note that the target feature was only introduced in v3.2.0 and isn't documented just yet: https://www.styled-components.com/releases#v3.2.0_stylesheetmanager-target-prop

markacola commented 6 years ago

I just tried this out and it works great! Just simply:

  const iframe = document.querySelector(".nc-previewPane-frame")
  const iframeHeadElem = iframe.contentDocument.head;

  return (
    <StyleSheetManager target={iframeHeadElem}>
      {/* styled elements */}
    </StyleSheetManager>
  )
erquhart commented 6 years ago

Leaving this open as I'd still like to discuss how our API might improve so that this isn't so manual. Perhaps we need some kind of preview plugin API that would allow a styled-components plugin to handle this behind the scenes.

Frithir commented 6 years ago

We got this working. It will bring in all styles from the front end URL.

if (
  window.location.hostname === 'localhost' &&
  window.localStorage.getItem('netlifySiteURL')
) {
  CMS.registerPreviewStyle(
    window.localStorage.getItem('netlifySiteURL') + '/styles.css'
  )
} else {
  CMS.registerPreviewStyle('/styles.css')
}

CMS.registerPreviewTemplate('home-page', ({ entry }) => (
  <HomePageTemplate {...entry.toJS().data} />
))
pungggi commented 6 years ago

@Firthir Can you elaborate what is the idea behind the localStorage Item?

Frithir commented 6 years ago

Hey @pungggi you'll find this useful if you're using localhost and logging into the CMS. This gets the preview to display correctly locally and live using the if else. I use the .env.development var for a few things in my dev. Try this, then we can use this with the example above.

OR in this example just hard code it into your custom JS file.

if (
  window.location.hostname === 'localhost' 
) {
  CMS.registerPreviewStyle( 'https://yoursstiehere.netlify.com/styles.css')
} else {
  CMS.registerPreviewStyle('/styles.css')
}

Include Custom JS into gatsby-plugin-netlify-cms in the gatsby-config.js file.

// all your other gatsby-config stuff above here..... 
// then set up gatsby-plugin-netlify-cms
{
      resolve: 'gatsby-plugin-netlify-cms',
      options: {
        modulePath: `${__dirname}/src/cms/cms.js`,
        stylesPath: `${__dirname}/src/cms/admin.css`,
        enableIdentityWidget: true,
      },
    },
    'gatsby-plugin-netlify', // make sure to keep it last in the array
  ],
}

Hope that helps you or someone else.

erquhart commented 6 years ago

@Firthir this issue is about CSS in JS solutions like Emotion, Styled Components, etc.

pungggi commented 5 years ago

@erquhart

I just tried this out and it works great! Just simply:

  const iframe = document.querySelector(".nc-previewPane-frame")
  const iframeHeadElem = iframe.contentDocument.head;

  return (
    <StyleSheetManager target={iframeHeadElem}>
      {/* styled elements */}
    </StyleSheetManager>
  )

Seems not to work with v2 https://github.com/netlify/netlify-cms/issues/1408#issuecomment-424965185

joserocha3 commented 5 years ago

@pungggi Replace the first line with

const iframe = document.getElementsByTagName('iframe')[0]
MiniCodeMonkey commented 5 years ago

For anyone using emotion, I solved this issue by using SSR on the fly to extract the css and then inject it into the nearest document (iframe) head. Very hacky but it works.

This worked great! Just make sure to import react as well.

import React from "react"
import { renderToString } from "react-dom/server"
import { renderStylesToString } from "emotion-server"

class CSSInjector extends React.Component {
  render() {
    return (
      <div
        ref={ref => {
          if (ref && !this.css) {
            this.css = renderStylesToString(renderToString(this.props.children))
            ref.ownerDocument.head.innerHTML += this.css
          }
        }}>
        {React.Children.only(this.props.children)}
      </div>
    )
  }
}

It works by wrapping your preview template:

CMS.registerPreviewTemplate("blog", props => (
  <CSSInjector>
    <BlogPreviewTemplate {...props} />
  </CSSInjector>
))
maciekmaciej commented 5 years ago

@robertgonzales Hey, now with emotion 10 we have to do something like this:

import React from 'react'
import createCache from '@emotion/cache'
import { CacheProvider } from '@emotion/core'

class CSSInjector extends React.Component {
  constructor() {
    super()
    const iframe = document.getElementsByTagName('iframe')[0]
    const iframeHead = iframe.contentDocument.head
    this.cache = createCache({ container: iframeHead })
  }

  render() {
    return (
      <CacheProvider value={this.cache}>
        {this.props.children}
      </CacheProvider>
    )
  }
}
Tomekmularczyk commented 5 years ago

document.getElementsByTagName('iframe')[0] didn't work for me. In my case for styled-components I had to do:

import React from "react";
import { StyleSheetManager } from "styled-components";

export default function StyledSheets({ children }) {
  const iframe = document.querySelector("#nc-root iframe");
  const iframeHeadElem = iframe && iframe.contentDocument.head;

  if (!iframeHeadElem) {
    return null;
  }

  return (
    <StyleSheetManager target={iframeHeadElem}>{children}</StyleSheetManager>
  );
}
homearanya commented 5 years ago

Hi there,

Has anyone been successful with Material-UI? Following similar approaches described above I got to this:

import React from 'react';
import { install } from '@material-ui/styles';
import { StylesProvider } from '@material-ui/styles';
import { jssPreset } from '@material-ui/core/styles';
import { create } from 'jss';
import camelCase from 'jss-plugin-camel-case';

install();

export default function MaterialUiSheets({ children }) {
  const iframe = document.querySelector('#nc-root iframe');
  const iframeHeadElem = iframe && iframe.contentDocument.head;

  if (!iframeHeadElem) {
    return null;
  }

  const jss = create({
    plugins: [...jssPreset().plugins, camelCase()],
    insertionPoint: iframeHeadElem.firstChild,
  });

  return <StylesProvider jss={jss}>{children}</StylesProvider>;
}

This only works partially, meaning not all the styles are injected into the Iframe.

This is probably a Material-UI question, which I'm not familiar with, but anyway someone might be able to help.

devolasvegas commented 5 years ago

Hi All!

I struggled with this a bit. For some reason setting this up (with styled-components ^4.2.0, netlify-cms-app 2.9.1, and netlify-cms-core 2.11.0) as a functional component wouldn't work, so I tried using the effect hook, which didn't work either. But perhaps my grasp of the effect hook isn't what it should be. Ended up setting it up as a class component which worked mostly, except for my theme.

So if you are using a theme with styled-components, you should be able to get this to work simply by wrapping your StyleSheetManager component with the ThemeProvider component. Perhaps there is a better way to do this, but I have spent enough time on this for now, and am moving on!

import React, { Component } from 'react';
import { StyleSheetManager, ThemeProvider } from 'styled-components';

import theme from '../styles/theme/theme';

class StylesheetInjector extends Component {
    constructor(props) {
        super(props);
        this.state = {
            iframeRef: '',
        };
    }

    componentDidMount() {
        const iframe = document.querySelector('#nc-root iframe');
        const iframeHeadElem = iframe && iframe.contentDocument.head;
        this.setState({ iframeRef: iframeHeadElem });
    }

    render() {
        return (
            <>
                {this.state.iframeRef && (
                    <ThemeProvider theme={theme}>
                        <StyleSheetManager target={this.state.iframeRef}>
                            {this.props.children}
                        </StyleSheetManager>
                    </ThemeProvider>
                )}
            </>
        );
    }
}

export default StylesheetInjector;
devolasvegas commented 5 years ago

Refactored using hooks:

import React, { useState, useEffect } from 'react';
import { StyleSheetManager, ThemeProvider } from 'styled-components';

import theme from '../styles/theme/theme';

const StylesheetInjector = ({ children }) => {
    const [iframeRef, setIframeRef] = useState(undefined);

    useEffect(() => {
        const iframe = document.querySelector('#nc-root iframe');
        const iframeHeadElem = iframe && iframe.contentDocument.head;
        setIframeRef(iframeHeadElem);
    });

    return (
        <>
            {iframeRef && (
                <ThemeProvider theme={theme}>
                    <StyleSheetManager target={iframeRef}>{children}</StyleSheetManager>
                </ThemeProvider>
            )}
        </>
    );
};

export default StylesheetInjector;
erquhart commented 5 years ago

Did that do the trick?

devolasvegas commented 5 years ago

Indeed, it did. I am also using createGlobalStyle and found that my global style component would work with this if I placed it inside the StyleSheetManager component, adjacent to { children }.

DreaminDani commented 5 years ago

@homearanya I was able to get material-ui styles to appear in the iFrame by following this approach: https://github.com/mui-org/material-ui/issues/13625#issuecomment-493608458

I ended up with the following, as an example:

import React from 'react'
import PropTypes from 'prop-types'
import CssBaseline from '@material-ui/core/CssBaseline';
import { ThemeProvider } from '@material-ui/styles';
import { create } from "jss";
import { jssPreset, StylesProvider } from "@material-ui/styles";
import theme from '../../theme';
import { AboutPageTemplate } from '../../templates/about-page'

class AboutPagePreview extends React.Component {
  state = {
    ready: false
  };

  handleRef = ref => {
    const ownerDocument = ref ? ref.ownerDocument : null;
    this.setState({
      ready: true,
      jss: create({
        ...jssPreset(),
        insertionPoint: ownerDocument ? ownerDocument.querySelector("#demo-frame-jss") : null
      }),
      sheetsManager: new Map()
    });
  };

  render() {
    const { entry, widgetFor } = this.props;
    const data = entry.getIn(['data']).toJS()

    if (data) {
      return (
        <React.Fragment>
          <div id="demo-frame-jss" ref={this.handleRef} />
          {this.state.ready ? (
          <StylesProvider
            jss={this.state.jss}
            sheetsManager={this.state.sheetsManager}
          >
            <ThemeProvider theme={theme}>
              {/* CssBaseline kickstart an elegant, consistent, and simple baseline to build upon. */}
              <CssBaseline />
              <AboutPageTemplate
                title={entry.getIn(['data', 'title'])}
                content={widgetFor('body')}
              />
           </ThemeProvider>
          </StylesProvider>
        ) : null}
        </React.Fragment>
      )
    } else {
      return <div>Loading...</div>
    }
  }
}

AboutPagePreview.propTypes = {
  entry: PropTypes.shape({
    getIn: PropTypes.func,
  }),
  widgetFor: PropTypes.func,
}

export default AboutPagePreview
talves commented 5 years ago

@d3sandoval this is close to how I'm handling the previews for material-ui also, but you might have a better implementation on the ref. Nice job.

homearanya commented 5 years ago

Thanks @d3sandoval, that looks like a nice approach. I like the way the callback ref is used.

I ended up using the approach that @talves show me on Gitter, which worked for me. Tony, I hope you don't mind that I share it here:

Basically, I render the full page style context into a string and inject that into a component for the preview. I also do the same for the Preview component.

PreviewContainer.js

import React from 'react'

export default props => (
  <React.Fragment>
    <style
      type="text/css"
      id={props.id}
      dangerouslySetInnerHTML={{ __html: props.innerStyles }}
    />
    <div
      dangerouslySetInnerHTML={{ __html: props.innerHTML }}
    />
  </React.Fragment>
)

withJss.js

function withJss(Component) {
  class WithRoot extends React.Component {
    constructor(props) {
      super(props);
      this.muiPageContext = getPageContext();
    }

    render() {
      return (
        <JssProvider registry={this.muiPageContext.sheetsRegistry}>
          {/* MuiThemeProvider makes the theme available down the React
              tree thanks to React context. */}
          <MuiThemeProvider
            theme={this.muiPageContext.theme}
            // sheetsManager={this.muiPageContext.sheetsManager}
          >
            {/* CssBaseline kickstart an elegant, consistent, and simple baseline to build upon. */}
            <CssBaseline />
            <Component {...this.props} />
          </MuiThemeProvider>
        </JssProvider>
      );
    }
  }

  return WithRoot;
}

export default withJss;

ServicesPreview .js

const styles = theme => ({
  root: {
    flexGrow: 1,
    marginTop: 5,
  },
  wrapper: {
    padding: 20,
  },
});
const StyledPreview = ({ classes, data }) => (
  <div className={classes.root}>
    <div className={classes.wrapper}>
      <PageHeader data={{title: data.services.title, description: data.services.introduction}} />
    </div>
    <div className={classes.wrapper}>
      <IntroSection data={data} />
      <Services data={data} />
    </div>
  </div>
)

const Preview = withJss(withStyles(styles)(StyledPreview))

export default class ServicesPreview extends React.Component {
  constructor(props) {
    super(props);
    this.muiPageContext = getPageContext()
  }
  render () {
    // const data = this.props.entry.toJS().data // This or next line
    const data = this.props.entry.getIn(['data']).toJS()
    const previewHTML = renderToString(<Preview data={{services: { ...data }}}/>)
    return (
      <PreviewContainer
        innerStyles={this.muiPageContext.sheetsRegistry.toString()}
        innerHTML={previewHTML}
      />
    )
  }
}

JssProvider in this case is the react-jss/lib/JssProvider component in the jss monorepo.

that example preview is pretty complicated, but wanted you to see what it is rendering. I have multiple components being rendered into one preview. As you can see, the muiPageContext.sheetsRegistry object is created by import { SheetsRegistry } from 'jss'

I must say @d3sandoval approach looks simpler

talves commented 5 years ago

@homearanya The simpler solution will work fine too, but I have some edge cases that the pre-render is required using the method above. A combination of these both would cover it.

I would start with the simpler approach and move from there (captain obvious 😜).

I wonder if the cms should be forwarding the ifame ref to the preview rather than a query.

yoavniran commented 5 years ago

thanks @devolasvegas that solved the issue for me!

created a short gist for my PreviewLayout component using the more appropriate (IMO) useRef & useLayoutEffect based on the example above -

https://gist.github.com/yoavniran/0953eff3d88f385431b9decc0c3a6be5

showing how to enable styled-components with netlify-cms

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.

erezrokah commented 4 years ago

Closing as the issue seems resolved. Please re-open if necessary

erquhart commented 4 years ago

This may merit staying open. What we have so far are instructions for individual libraries, most of which are on the tedious side. At a minimum we should document this stuff, at a maximum we should have a simpler support model.

Sent with GitHawk

erezrokah commented 4 years ago

I re-opened an added a pinned label so it won't get marked as stale. I have a suggestion though, we can close this one and open a new issue with the title "Document how to use with CSS in JS libs" and link to this issue for reference. I think once we start writing the documentation it will make it easier to decide if that is enough or we should make some code changes.

What do you think?

bencao commented 4 years ago

Thanks for the previous comments, which inspired me a lot!

Short Term Solution

In my opinion, the most natural solution is to create a higher-order component that adds support to a specific CSS-IN-JS library.

For example, I would add support for styled-components and emotion with the following snippet today:

// src/cms/with-styled.js,  define the higher-order function to support styled

import React from "react";
import { StyleSheetManager } from "styled-components";

export default Component => props => {
  const iframe = document.querySelector("#nc-root iframe");
  const iframeHeadElem = iframe && iframe.contentDocument.head;

  if (!iframeHeadElem) {
    return null;
  }

  return (
    <StyleSheetManager target={iframeHeadElem}>
      <Component {...props} />
    </StyleSheetManager>
  );
};
// src/cms/with-emotion.js,  define the higher-order function to support emotion

import React from "react";
import { CacheProvider } from "@emotion/core";
import createCache from "@emotion/cache";
import weakMemoize from "@emotion/weak-memoize";

const memoizedCreateCacheWithContainer = weakMemoize(container => {
  let newCache = createCache({ container });
  return newCache;
});

export default Component => props => {
  const iframe = document.querySelector("#nc-root iframe");
  const iframeHeadElem = iframe && iframe.contentDocument.head;

  if (!iframeHeadElem) {
    return null;
  }

  return (
    <CacheProvider value={memoizedCreateCacheWithContainer(iframeHeadElem)}>
      <Component {...props} />
    </CacheProvider>
  );
};
// src/cms/cms.js, use higher-order functions defined above

import CMS from "netlify-cms-app";
import withStyled from "./with-styled";
import withEmotion from "./with-emotion";

import UserPreview from "./preview-templates/UserPreview";
import OrderPreview from "./preview-templates/OrderPreview";

CMS.registerPreviewTemplate("user", withStyled(UserPreview));
CMS.registerPreviewTemplate("order", withEmotion(OrderPreview));

Long Term Solution

But for the long term, in order to achieve best possible user experience, it would be best to hide these dirty details and try to detect whether the project is using styled-component or css-modules and add support for these CSS-IN-JS libraries automagically, within the lower level CMS.registerPreviewTemplate function:


function smartRegisterPreviewTemplate(name, component) {
  // check styled-components
  try {
     require("styled-components");

     return registerPreviewTemplate(name, withStyled(component));
  } catch (styledNotFound) {
     // do nothing
  }

  // check emotion
  try {
     require("@emotion/core");

     return registerPreviewTemplate(name, withEmotion(component));
  } catch (emotionNotFound) {
     // do nothing
  }

  // not using any css-in-js library
  return registerPreviewTemplate(name, component);
}
emileswain commented 3 years ago

Should this work for nextjs implementations as well? I'm using css modules, and have failed to get any of the examples to work. Nextjs is embeded the styles into the admin page as